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 ( 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..608d77f 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,8 @@ 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'; +import CreateExchangeRateDialog from '@components/CreateExchangeRateDialog'; export default function ExchangeRatesPage() { const { data: polygonApiKey } = client.getValue.useQuery({ @@ -51,12 +56,22 @@ export default function ExchangeRatesPage() { onOpen: onCalculatorDialogOpen, onClose: onCalculatorDialogClose, } = useDialog(); + const { + open: isUpdateDialogOpen, + onOpen: onUpdateDialogOpen, + onClose: onUpdateDialogClose, + } = 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 +103,9 @@ export default function ExchangeRatesPage() { + + + @@ -104,17 +122,73 @@ export default function ExchangeRatesPage() { onUpdate={updateValue} /> )} - {isCalculatorDialogOpen && rates && ( + {isCalculatorDialogOpen && formRates && ( + )} + {isUpdateDialogOpen && formRates && ( + )} - + ); } + +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/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 d6d3a7e..28e715e 100644 --- a/src/server/exchangeRates/types.ts +++ b/src/server/exchangeRates/types.ts @@ -15,22 +15,30 @@ export const ExchangeRate = z.object({ export const GetExchangeRatesInput = z.void(); export const GetExchangeRatesOutput = z.array(ExchangeRate); -export const UpsertExchangeRateInput = z.object({ +export const CreateExchangeRateInput = z.object({ ticker: z.string(), - open: z.number(), - low: z.number(), - high: z.number(), close: z.number(), }); -export const UpsertExchangeRateOutput = ExchangeRate; +export const CreateExchangeRateOutput = 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 CreateExchangeRateInput = z.infer; +export type CreateExchangeRateOutput = 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..9dd6502 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -26,13 +26,14 @@ 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'; +import createExchangeRate from './exchangeRates/createExchangeRate'; const router = trpc.router({ helloWorld: procedure @@ -65,7 +66,8 @@ const router = trpc.router({ getBudget, updateBudget, getExchangeRates, - upsertExchangeRate, + createExchangeRate, + updateExchangeRates, refreshExchangeRates, getValue, updateValue, 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,