From fd3bf2e2112f5b0eda5104fc759e6769d4af5739 Mon Sep 17 00:00:00 2001 From: David Saltares Date: Tue, 24 Sep 2024 10:41:53 +0200 Subject: [PATCH 1/2] feat: 6 - inline exchange rate editing --- src/components/ExchangeRatesTable.tsx | 45 +++++++++++-- src/routes/ExchangeRatesPage.tsx | 64 ++++++++++++++++++- src/server/exchangeRates/types.ts | 21 +++--- .../exchangeRates/updateExchangeRates.ts | 35 ++++++++++ .../exchangeRates/upsertExchangeRate.ts | 44 ------------- src/server/router.ts | 4 +- 6 files changed, 149 insertions(+), 64 deletions(-) create mode 100644 src/server/exchangeRates/updateExchangeRates.ts delete mode 100644 src/server/exchangeRates/upsertExchangeRate.ts diff --git a/src/components/ExchangeRatesTable.tsx b/src/components/ExchangeRatesTable.tsx index 5474b40..1aea258 100644 --- a/src/components/ExchangeRatesTable.tsx +++ b/src/components/ExchangeRatesTable.tsx @@ -19,11 +19,13 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import TableCell from '@mui/material/TableCell'; import TableSortLabel from '@mui/material/TableSortLabel'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; import flags from '@lib/flags'; import useFiltersFromUrl from '@lib/useFiltersFromUrl'; import useSortFromUrl from '@lib/useSortFromUrl'; import type { ExchangeRate } from '@server/exchangeRates/types'; -import { formatAmount, formatDate } from '@lib/format'; +import { formatDate } from '@lib/format'; export const DefaultSort = { id: 'ticker', desc: true }; @@ -33,10 +35,14 @@ const ExchangeRateTableRow = memo(ExchangeRateTableRowBase); type Props = { rates: ExchangeRate[]; + onUpdateRate: (idx: number, rate: ExchangeRate) => void; }; -export default function ExchangeRatesTableTable({ rates }: Props) { - const table = useExchangeRatesTable(rates); +export default function ExchangeRatesTableTable({ + rates, + onUpdateRate, +}: Props) { + const table = useExchangeRatesTable(rates, onUpdateRate); return ( @@ -54,7 +60,10 @@ export default function ExchangeRatesTableTable({ rates }: Props) { ); } -function useExchangeRatesTable(rates: ExchangeRate[]) { +function useExchangeRatesTable( + rates: ExchangeRate[], + onUpdateRate: Props['onUpdateRate'], +) { const { sorting } = useSortFromUrl(DefaultSort); const { filtersByField } = useFiltersFromUrl(); @@ -87,9 +96,31 @@ function useExchangeRatesTable(rates: ExchangeRate[]) { columnHelper.accessor('close', { header: 'Rate', cell: (info) => ( - - {formatAmount(info.getValue(), info.row.original.code)} - + + {info.row.original.code} + + ), + }, + }} + inputProps={{ + step: 0.0001, + min: 0, + style: { textAlign: 'right' }, + }} + onChange={(e) => + onUpdateRate(info.row.index, { + ...info.row.original, + close: parseFloat(e.target.value), + }) + } + /> ), meta: { numeric: true }, }), diff --git a/src/routes/ExchangeRatesPage.tsx b/src/routes/ExchangeRatesPage.tsx index e04cb3e..c4bd5a7 100644 --- a/src/routes/ExchangeRatesPage.tsx +++ b/src/routes/ExchangeRatesPage.tsx @@ -3,10 +3,13 @@ import SettingsIcon from '@mui/icons-material/Settings'; import CalculateIcon from '@mui/icons-material/Calculate'; import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange'; import AddIcon from '@mui/icons-material/Add'; +import SaveIcon from '@mui/icons-material/Save'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; import IconButton from '@mui/material/IconButton'; import { enqueueSnackbar } from 'notistack'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { useCallback, useEffect } from 'react'; import client from '@lib/client'; import AppName from '@lib/appName'; import FullScreenSpinner from '@components/Layout/FullScreenSpinner'; @@ -19,6 +22,7 @@ import ExchangeRatesSettingsDialog from '@components/ExchangeRatesSettingsDialog import { PolygonApiKeySettingName } from '@lib/getPolygonRates'; import ExchangeRateCalculatorDialog from '@components/ExchangeRateCalculatorDialog'; import RefreshRatesButton from '@components/RefreshRatesButton'; +import type { ExchangeRate } from '@server/exchangeRates/types'; export default function ExchangeRatesPage() { const { data: polygonApiKey } = client.getValue.useQuery({ @@ -52,11 +56,16 @@ export default function ExchangeRatesPage() { onClose: onCalculatorDialogClose, } = useDialog(); + const { handleUpdateRates, isUpdating, formRates, updateRate } = + useExchangeRatesForm(rates); + let content = null; if (isLoading) { content = ; } else if (rates && rates.length > 0) { - content = ; + content = ( + + ); } else { content = ( @@ -88,6 +97,9 @@ export default function ExchangeRatesPage() { + + + @@ -118,3 +130,53 @@ export default function ExchangeRatesPage() { ); } + +type ExchangeRatesFormValues = { + rates: ExchangeRate[]; +}; + +const useExchangeRatesForm = (rates: ExchangeRate[] | undefined) => { + const { mutate: updateExchangeRates, isPending: isUpdating } = + client.updateExchangeRates.useMutation({ + onSuccess: () => { + enqueueSnackbar({ + message: 'Exchange rates updated.', + variant: 'success', + }); + }, + onError: (e) => { + enqueueSnackbar({ + message: `Failed to update exchange rates. ${e.message}`, + variant: 'error', + }); + }, + }); + + const { control } = useForm({ + mode: 'onBlur', + defaultValues: { + rates: rates || [], + }, + }); + const { + fields: formRates, + update: updateRate, + replace: replaceRates, + } = useFieldArray({ + control, + name: 'rates', + }); + + useEffect(() => replaceRates(rates || []), [rates, replaceRates]); + + const handleUpdateRates = useCallback(() => { + updateExchangeRates(formRates); + }, [formRates, updateExchangeRates]); + + return { + handleUpdateRates, + isUpdating, + formRates, + updateRate, + }; +}; diff --git a/src/server/exchangeRates/types.ts b/src/server/exchangeRates/types.ts index d6d3a7e..75a724a 100644 --- a/src/server/exchangeRates/types.ts +++ b/src/server/exchangeRates/types.ts @@ -15,22 +15,23 @@ export const ExchangeRate = z.object({ export const GetExchangeRatesInput = z.void(); export const GetExchangeRatesOutput = z.array(ExchangeRate); -export const UpsertExchangeRateInput = z.object({ - ticker: z.string(), - open: z.number(), - low: z.number(), - high: z.number(), - close: z.number(), -}); -export const UpsertExchangeRateOutput = ExchangeRate; +export const UpdateExchangeRatesInput = z.array( + z.object({ + ticker: z.string(), + close: z.number(), + }), +); +export const UpdateExchangeRatesOutput = z.void(); export const RefreshExchangeRateInput = z.void(); export const RefreshExchangeRateOutput = z.void(); export type ExchangeRate = z.infer; export type GetExchangeRatesInput = z.infer; export type GetExchangeRatesOutput = z.infer; -export type UpsertExchangeRateInput = z.infer; -export type UpsertExchangeRateOutput = z.infer; +export type UpdateExchangeRatesInput = z.infer; +export type UpdateExchangeRatesOutput = z.infer< + typeof UpdateExchangeRatesOutput +>; export type RefreshExchangeRateInput = z.infer; export type RefreshExchangeRateOutput = z.infer< typeof RefreshExchangeRateOutput diff --git a/src/server/exchangeRates/updateExchangeRates.ts b/src/server/exchangeRates/updateExchangeRates.ts new file mode 100644 index 0000000..bfb2045 --- /dev/null +++ b/src/server/exchangeRates/updateExchangeRates.ts @@ -0,0 +1,35 @@ +import { type Procedure, procedure } from '@server/trpc'; +import db from '@server/db'; +import { UpdateExchangeRatesInput, UpdateExchangeRatesOutput } from './types'; + +const updateExchangeRates: Procedure< + UpdateExchangeRatesInput, + UpdateExchangeRatesOutput +> = async ({ input }) => { + const rates = input.map(({ ticker, close }) => ({ + ticker, + close, + open: close, + low: close, + high: close, + date: new Date().toISOString(), + })); + await db.transaction().execute(async (trx) => { + for (const rate of rates) { + const { ticker, ...rest } = rate; + await trx + .insertInto('exchangeRate') + .values({ + ticker, + ...rest, + }) + .onConflict((oc) => oc.column('ticker').doUpdateSet(rest)) + .execute(); + } + }); +}; + +export default procedure + .input(UpdateExchangeRatesInput) + .output(UpdateExchangeRatesOutput) + .mutation(updateExchangeRates); diff --git a/src/server/exchangeRates/upsertExchangeRate.ts b/src/server/exchangeRates/upsertExchangeRate.ts deleted file mode 100644 index 1d0e3a8..0000000 --- a/src/server/exchangeRates/upsertExchangeRate.ts +++ /dev/null @@ -1,44 +0,0 @@ -import currencyCodes from 'currency-codes'; -import { type Procedure, procedure } from '@server/trpc'; -import db from '@server/db'; -import createUTCDate from '@lib/createUTCDate'; -import { UpsertExchangeRateInput, UpsertExchangeRateOutput } from './types'; - -const upsertExchangeRate: Procedure< - UpsertExchangeRateInput, - UpsertExchangeRateOutput -> = async ({ input: { ticker, open, close, high, low } }) => { - const rate = await db - .insertInto('exchangeRate') - .values({ - ticker, - open, - close, - high, - low, - date: createUTCDate().toISOString(), - }) - .onConflict((oc) => - oc.column('ticker').doUpdateSet({ - open, - close, - high, - low, - date: createUTCDate().toISOString(), - }), - ) - .returningAll() - .executeTakeFirstOrThrow(); - - const data = currencyCodes.code(rate.ticker.replace('EUR', '')); - return { - ...rate, - code: data?.code || '', - currency: data?.currency || '', - }; -}; - -export default procedure - .input(UpsertExchangeRateInput) - .output(UpsertExchangeRateOutput) - .mutation(upsertExchangeRate); diff --git a/src/server/router.ts b/src/server/router.ts index a143944..365b0d6 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -26,13 +26,13 @@ import getIncomeVsExpensesReport from './reports/getIncomeVsExpensesReport'; import updateBudget from './budget/updateBudget'; import getBudget from './budget/getBudget'; import getExchangeRates from './exchangeRates/getExchangeRates'; -import upsertExchangeRate from './exchangeRates/upsertExchangeRate'; import refreshExchangeRates from './exchangeRates/refreshExchangeRates'; import getValue from './keyValue/getValue'; import updateValue from './keyValue/updateValue'; import updateUserSettings from './userSettings/updateUserSettings'; import getUserSettings from './userSettings/getUserSettings'; import showFileSaveDialog from './main/showFileSaveDialog'; +import updateExchangeRates from './exchangeRates/updateExchangeRates'; const router = trpc.router({ helloWorld: procedure @@ -65,7 +65,7 @@ const router = trpc.router({ getBudget, updateBudget, getExchangeRates, - upsertExchangeRate, + updateExchangeRates, refreshExchangeRates, getValue, updateValue, From 7c66ec082953f271ddcf4cc899f54ee32fd89af1 Mon Sep 17 00:00:00 2001 From: David Saltares Date: Thu, 26 Sep 2024 12:44:25 +0200 Subject: [PATCH 2/2] feat: 6 - ability to manually add rates --- src/components/CreateExchangeRateDialog.tsx | 137 ++++++++++++++++++ src/components/CurrencyAutocomplete.tsx | 1 + src/routes/ExchangeRatesPage.tsx | 18 ++- .../exchangeRates/createExchangeRate.ts | 35 +++++ src/server/exchangeRates/types.ts | 7 + src/server/router.ts | 2 + src/server/transactions/utils.ts | 1 - 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 src/components/CreateExchangeRateDialog.tsx create mode 100644 src/server/exchangeRates/createExchangeRate.ts diff --git a/src/components/CreateExchangeRateDialog.tsx b/src/components/CreateExchangeRateDialog.tsx new file mode 100644 index 0000000..e6db562 --- /dev/null +++ b/src/components/CreateExchangeRateDialog.tsx @@ -0,0 +1,137 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import { useTheme } from '@mui/material/styles'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import TextField from '@mui/material/TextField'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { type SubmitHandler, useForm, Controller } from 'react-hook-form'; +import Stack from '@mui/material/Stack'; +import { useCallback } from 'react'; +import { enqueueSnackbar } from 'notistack'; +import DialogContentText from '@mui/material/DialogContentText'; +import { currencyOptionsById } from '@lib/autoCompleteOptions'; +import { formatNumber } from '@lib/format'; +import client from '@lib/client'; +import CurrencyAutocomplete from './CurrencyAutocomplete'; + +type Props = { + open: boolean; + onClose: () => void; +}; + +const id = 'create-exchangerate-dialog'; + +type Option = { label: string; id: string }; +type ExchgangeRateFormValues = { + currency: Option; + close: string; +}; + +export default function CreateExchangeRateDialog({ open, onClose }: Props) { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + const { + control, + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + mode: 'onBlur', + defaultValues: { + currency: currencyOptionsById['USD'], + close: formatNumber(1.0), + }, + }); + const { mutate: createExchangeRate, isPending: isCreating } = + client.createExchangeRate.useMutation({ + onSuccess: () => { + enqueueSnackbar({ + message: 'Exchange rate created.', + variant: 'success', + }); + onClose(); + }, + onError: (e) => { + enqueueSnackbar({ + message: `Failed to create exchange rate. ${e.message}`, + variant: 'error', + }); + }, + }); + const onSubmit: SubmitHandler = useCallback( + async (values) => + createExchangeRate({ + ticker: `EUR${values.currency.id}`, + close: parseFloat(values.close), + }), + [createExchangeRate], + ); + + return ( + +
+ Edit exchange rate + + + + 🇪🇺 EUR is always the base currecy + + + ( + + )} + /> + + + + + + + + Save + + +
+
+ ); +} diff --git a/src/components/CurrencyAutocomplete.tsx b/src/components/CurrencyAutocomplete.tsx index 3413fd2..31dcf82 100644 --- a/src/components/CurrencyAutocomplete.tsx +++ b/src/components/CurrencyAutocomplete.tsx @@ -27,6 +27,7 @@ export default function CurrencyAutocomplete({ }: Props) { return ( )} - {isCalculatorDialogOpen && rates && ( + {isCalculatorDialogOpen && formRates && ( + )} + {isUpdateDialogOpen && formRates && ( + )} - + diff --git a/src/server/exchangeRates/createExchangeRate.ts b/src/server/exchangeRates/createExchangeRate.ts new file mode 100644 index 0000000..2a844d1 --- /dev/null +++ b/src/server/exchangeRates/createExchangeRate.ts @@ -0,0 +1,35 @@ +import currencyCodes from 'currency-codes'; +import { type Procedure, procedure } from '@server/trpc'; +import db from '@server/db'; +import { CreateExchangeRateInput, CreateExchangeRateOutput } from './types'; + +const createExchangeRate: Procedure< + CreateExchangeRateInput, + CreateExchangeRateOutput +> = async ({ input: { ticker, close } }) => { + console.log('ticker', ticker); + console.log('close', close); + const inserted = await db + .insertInto('exchangeRate') + .values({ + ticker, + close, + open: close, + low: close, + high: close, + date: new Date().toISOString(), + }) + .returningAll() + .executeTakeFirstOrThrow(); + const data = currencyCodes.code(inserted.ticker.replace('EUR', '')); + return { + ...inserted, + code: data?.code || '', + currency: data?.currency || '', + }; +}; + +export default procedure + .input(CreateExchangeRateInput) + .output(CreateExchangeRateOutput) + .mutation(createExchangeRate); diff --git a/src/server/exchangeRates/types.ts b/src/server/exchangeRates/types.ts index 75a724a..28e715e 100644 --- a/src/server/exchangeRates/types.ts +++ b/src/server/exchangeRates/types.ts @@ -15,6 +15,11 @@ export const ExchangeRate = z.object({ export const GetExchangeRatesInput = z.void(); export const GetExchangeRatesOutput = z.array(ExchangeRate); +export const CreateExchangeRateInput = z.object({ + ticker: z.string(), + close: z.number(), +}); +export const CreateExchangeRateOutput = ExchangeRate; export const UpdateExchangeRatesInput = z.array( z.object({ ticker: z.string(), @@ -28,6 +33,8 @@ export const RefreshExchangeRateOutput = z.void(); export type ExchangeRate = z.infer; export type GetExchangeRatesInput = z.infer; export type GetExchangeRatesOutput = z.infer; +export type CreateExchangeRateInput = z.infer; +export type CreateExchangeRateOutput = z.infer; export type UpdateExchangeRatesInput = z.infer; export type UpdateExchangeRatesOutput = z.infer< typeof UpdateExchangeRatesOutput diff --git a/src/server/router.ts b/src/server/router.ts index 365b0d6..9dd6502 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -33,6 +33,7 @@ import updateUserSettings from './userSettings/updateUserSettings'; import getUserSettings from './userSettings/getUserSettings'; import showFileSaveDialog from './main/showFileSaveDialog'; import updateExchangeRates from './exchangeRates/updateExchangeRates'; +import createExchangeRate from './exchangeRates/createExchangeRate'; const router = trpc.router({ helloWorld: procedure @@ -65,6 +66,7 @@ const router = trpc.router({ getBudget, updateBudget, getExchangeRates, + createExchangeRate, updateExchangeRates, refreshExchangeRates, getValue, diff --git a/src/server/transactions/utils.ts b/src/server/transactions/utils.ts index 55e4654..a8578d7 100644 --- a/src/server/transactions/utils.ts +++ b/src/server/transactions/utils.ts @@ -46,7 +46,6 @@ export function getDateWhereFromFilter(date: DateFilter | undefined) { }; } else if (isPeriod(date)) { const [gte, lte] = getDateRangeForPeriod(date); - console.log('PERIOD', gte, lte); return { gte, lte,