Skip to content

Commit 432260e

Browse files
authored
Merge pull request #7 from dsaltares/feat/6/manual-fx
feat: 6 - manual rate adding/editing
2 parents 9e4eee6 + 7c66ec0 commit 432260e

10 files changed

+343
-65
lines changed
+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import LoadingButton from '@mui/lab/LoadingButton';
2+
import { useTheme } from '@mui/material/styles';
3+
import Button from '@mui/material/Button';
4+
import Dialog from '@mui/material/Dialog';
5+
import DialogActions from '@mui/material/DialogActions';
6+
import DialogContent from '@mui/material/DialogContent';
7+
import DialogTitle from '@mui/material/DialogTitle';
8+
import TextField from '@mui/material/TextField';
9+
import useMediaQuery from '@mui/material/useMediaQuery';
10+
import { type SubmitHandler, useForm, Controller } from 'react-hook-form';
11+
import Stack from '@mui/material/Stack';
12+
import { useCallback } from 'react';
13+
import { enqueueSnackbar } from 'notistack';
14+
import DialogContentText from '@mui/material/DialogContentText';
15+
import { currencyOptionsById } from '@lib/autoCompleteOptions';
16+
import { formatNumber } from '@lib/format';
17+
import client from '@lib/client';
18+
import CurrencyAutocomplete from './CurrencyAutocomplete';
19+
20+
type Props = {
21+
open: boolean;
22+
onClose: () => void;
23+
};
24+
25+
const id = 'create-exchangerate-dialog';
26+
27+
type Option = { label: string; id: string };
28+
type ExchgangeRateFormValues = {
29+
currency: Option;
30+
close: string;
31+
};
32+
33+
export default function CreateExchangeRateDialog({ open, onClose }: Props) {
34+
const theme = useTheme();
35+
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
36+
const {
37+
control,
38+
register,
39+
handleSubmit,
40+
formState: { errors, isValid },
41+
} = useForm<ExchgangeRateFormValues>({
42+
mode: 'onBlur',
43+
defaultValues: {
44+
currency: currencyOptionsById['USD'],
45+
close: formatNumber(1.0),
46+
},
47+
});
48+
const { mutate: createExchangeRate, isPending: isCreating } =
49+
client.createExchangeRate.useMutation({
50+
onSuccess: () => {
51+
enqueueSnackbar({
52+
message: 'Exchange rate created.',
53+
variant: 'success',
54+
});
55+
onClose();
56+
},
57+
onError: (e) => {
58+
enqueueSnackbar({
59+
message: `Failed to create exchange rate. ${e.message}`,
60+
variant: 'error',
61+
});
62+
},
63+
});
64+
const onSubmit: SubmitHandler<ExchgangeRateFormValues> = useCallback(
65+
async (values) =>
66+
createExchangeRate({
67+
ticker: `EUR${values.currency.id}`,
68+
close: parseFloat(values.close),
69+
}),
70+
[createExchangeRate],
71+
);
72+
73+
return (
74+
<Dialog
75+
open={open}
76+
onClose={onClose}
77+
id={id}
78+
aria-labelledby={`${id}-title`}
79+
fullScreen={fullScreen}
80+
keepMounted={false}
81+
fullWidth
82+
maxWidth="md"
83+
>
84+
<form onSubmit={handleSubmit(onSubmit)}>
85+
<DialogTitle id={`${id}-title`}>Edit exchange rate</DialogTitle>
86+
<DialogContent>
87+
<Stack gap={4}>
88+
<DialogContentText fontStyle="italic" fontSize="sm">
89+
🇪🇺 EUR is always the base currecy
90+
</DialogContentText>
91+
<Stack direction="row" alignItems="center" gap={1}>
92+
<Controller
93+
control={control}
94+
name="currency"
95+
rules={{ required: true }}
96+
render={({ field: { value, onChange, onBlur } }) => (
97+
<CurrencyAutocomplete
98+
value={value}
99+
onChange={onChange}
100+
onBlur={onBlur}
101+
error={!!errors.currency}
102+
/>
103+
)}
104+
/>
105+
<TextField
106+
fullWidth
107+
required
108+
type="number"
109+
label="Rate"
110+
error={!!errors.close}
111+
inputProps={{
112+
step: 0.0001,
113+
min: 0.0001,
114+
}}
115+
{...register('close', { required: true })}
116+
/>
117+
</Stack>
118+
</Stack>
119+
</DialogContent>
120+
<DialogActions>
121+
<Button variant="outlined" onClick={onClose}>
122+
Cancel
123+
</Button>
124+
<LoadingButton
125+
type="submit"
126+
variant="contained"
127+
color="secondary"
128+
loading={isCreating}
129+
disabled={!isValid}
130+
>
131+
Save
132+
</LoadingButton>
133+
</DialogActions>
134+
</form>
135+
</Dialog>
136+
);
137+
}

src/components/CurrencyAutocomplete.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default function CurrencyAutocomplete({
2727
}: Props) {
2828
return (
2929
<Autocomplete
30+
fullWidth
3031
disableClearable
3132
id="currency-autocomplete"
3233
value={value}

src/components/ExchangeRatesTable.tsx

+38-7
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ import TableHead from '@mui/material/TableHead';
1919
import TableRow from '@mui/material/TableRow';
2020
import TableCell from '@mui/material/TableCell';
2121
import TableSortLabel from '@mui/material/TableSortLabel';
22+
import TextField from '@mui/material/TextField';
23+
import InputAdornment from '@mui/material/InputAdornment';
2224
import flags from '@lib/flags';
2325
import useFiltersFromUrl from '@lib/useFiltersFromUrl';
2426
import useSortFromUrl from '@lib/useSortFromUrl';
2527
import type { ExchangeRate } from '@server/exchangeRates/types';
26-
import { formatAmount, formatDate } from '@lib/format';
28+
import { formatDate } from '@lib/format';
2729

2830
export const DefaultSort = { id: 'ticker', desc: true };
2931

@@ -33,10 +35,14 @@ const ExchangeRateTableRow = memo(ExchangeRateTableRowBase);
3335

3436
type Props = {
3537
rates: ExchangeRate[];
38+
onUpdateRate: (idx: number, rate: ExchangeRate) => void;
3639
};
3740

38-
export default function ExchangeRatesTableTable({ rates }: Props) {
39-
const table = useExchangeRatesTable(rates);
41+
export default function ExchangeRatesTableTable({
42+
rates,
43+
onUpdateRate,
44+
}: Props) {
45+
const table = useExchangeRatesTable(rates, onUpdateRate);
4046

4147
return (
4248
<Paper>
@@ -54,7 +60,10 @@ export default function ExchangeRatesTableTable({ rates }: Props) {
5460
);
5561
}
5662

57-
function useExchangeRatesTable(rates: ExchangeRate[]) {
63+
function useExchangeRatesTable(
64+
rates: ExchangeRate[],
65+
onUpdateRate: Props['onUpdateRate'],
66+
) {
5867
const { sorting } = useSortFromUrl(DefaultSort);
5968
const { filtersByField } = useFiltersFromUrl();
6069

@@ -87,9 +96,31 @@ function useExchangeRatesTable(rates: ExchangeRate[]) {
8796
columnHelper.accessor('close', {
8897
header: 'Rate',
8998
cell: (info) => (
90-
<Typography fontSize="inherit">
91-
{formatAmount(info.getValue(), info.row.original.code)}
92-
</Typography>
99+
<TextField
100+
defaultValue={info.getValue() || 1.0}
101+
type="number"
102+
size="small"
103+
slotProps={{
104+
input: {
105+
endAdornment: (
106+
<InputAdornment position="end">
107+
{info.row.original.code}
108+
</InputAdornment>
109+
),
110+
},
111+
}}
112+
inputProps={{
113+
step: 0.0001,
114+
min: 0,
115+
style: { textAlign: 'right' },
116+
}}
117+
onChange={(e) =>
118+
onUpdateRate(info.row.index, {
119+
...info.row.original,
120+
close: parseFloat(e.target.value),
121+
})
122+
}
123+
/>
93124
),
94125
meta: { numeric: true },
95126
}),

src/routes/ExchangeRatesPage.tsx

+78-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import SettingsIcon from '@mui/icons-material/Settings';
33
import CalculateIcon from '@mui/icons-material/Calculate';
44
import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange';
55
import AddIcon from '@mui/icons-material/Add';
6+
import SaveIcon from '@mui/icons-material/Save';
67
import Stack from '@mui/material/Stack';
78
import TextField from '@mui/material/TextField';
89
import IconButton from '@mui/material/IconButton';
910
import { enqueueSnackbar } from 'notistack';
11+
import { useFieldArray, useForm } from 'react-hook-form';
12+
import { useCallback, useEffect } from 'react';
1013
import client from '@lib/client';
1114
import AppName from '@lib/appName';
1215
import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
@@ -19,6 +22,8 @@ import ExchangeRatesSettingsDialog from '@components/ExchangeRatesSettingsDialog
1922
import { PolygonApiKeySettingName } from '@lib/getPolygonRates';
2023
import ExchangeRateCalculatorDialog from '@components/ExchangeRateCalculatorDialog';
2124
import RefreshRatesButton from '@components/RefreshRatesButton';
25+
import type { ExchangeRate } from '@server/exchangeRates/types';
26+
import CreateExchangeRateDialog from '@components/CreateExchangeRateDialog';
2227

2328
export default function ExchangeRatesPage() {
2429
const { data: polygonApiKey } = client.getValue.useQuery({
@@ -51,12 +56,22 @@ export default function ExchangeRatesPage() {
5156
onOpen: onCalculatorDialogOpen,
5257
onClose: onCalculatorDialogClose,
5358
} = useDialog();
59+
const {
60+
open: isUpdateDialogOpen,
61+
onOpen: onUpdateDialogOpen,
62+
onClose: onUpdateDialogClose,
63+
} = useDialog();
64+
65+
const { handleUpdateRates, isUpdating, formRates, updateRate } =
66+
useExchangeRatesForm(rates);
5467

5568
let content = null;
5669
if (isLoading) {
5770
content = <FullScreenSpinner />;
5871
} else if (rates && rates.length > 0) {
59-
content = <ExchangeRatesTable rates={rates || []} />;
72+
content = (
73+
<ExchangeRatesTable rates={formRates} onUpdateRate={updateRate} />
74+
);
6075
} else {
6176
content = (
6277
<EmptyState Icon={CurrencyExchangeIcon}>
@@ -88,6 +103,9 @@ export default function ExchangeRatesPage() {
88103
<IconButton color="primary" disabled={!rates}>
89104
<CalculateIcon onClick={onCalculatorDialogOpen} />
90105
</IconButton>
106+
<IconButton color="primary" disabled={isUpdating}>
107+
<SaveIcon onClick={handleUpdateRates} />
108+
</IconButton>
91109
<RefreshRatesButton />
92110
<IconButton color="primary">
93111
<SettingsIcon onClick={onSettingsDialogOpen} />
@@ -104,17 +122,73 @@ export default function ExchangeRatesPage() {
104122
onUpdate={updateValue}
105123
/>
106124
)}
107-
{isCalculatorDialogOpen && rates && (
125+
{isCalculatorDialogOpen && formRates && (
108126
<ExchangeRateCalculatorDialog
109127
open={isCalculatorDialogOpen}
110128
onClose={onCalculatorDialogClose}
111-
rates={rates}
129+
rates={formRates}
130+
/>
131+
)}
132+
{isUpdateDialogOpen && formRates && (
133+
<CreateExchangeRateDialog
134+
open={isUpdateDialogOpen}
135+
onClose={onUpdateDialogClose}
112136
/>
113137
)}
114138
</Stack>
115-
<Fab aria-label="New rate">
139+
<Fab aria-label="New rate" onClick={onUpdateDialogOpen}>
116140
<AddIcon />
117141
</Fab>
118142
</>
119143
);
120144
}
145+
146+
type ExchangeRatesFormValues = {
147+
rates: ExchangeRate[];
148+
};
149+
150+
const useExchangeRatesForm = (rates: ExchangeRate[] | undefined) => {
151+
const { mutate: updateExchangeRates, isPending: isUpdating } =
152+
client.updateExchangeRates.useMutation({
153+
onSuccess: () => {
154+
enqueueSnackbar({
155+
message: 'Exchange rates updated.',
156+
variant: 'success',
157+
});
158+
},
159+
onError: (e) => {
160+
enqueueSnackbar({
161+
message: `Failed to update exchange rates. ${e.message}`,
162+
variant: 'error',
163+
});
164+
},
165+
});
166+
167+
const { control } = useForm<ExchangeRatesFormValues>({
168+
mode: 'onBlur',
169+
defaultValues: {
170+
rates: rates || [],
171+
},
172+
});
173+
const {
174+
fields: formRates,
175+
update: updateRate,
176+
replace: replaceRates,
177+
} = useFieldArray({
178+
control,
179+
name: 'rates',
180+
});
181+
182+
useEffect(() => replaceRates(rates || []), [rates, replaceRates]);
183+
184+
const handleUpdateRates = useCallback(() => {
185+
updateExchangeRates(formRates);
186+
}, [formRates, updateExchangeRates]);
187+
188+
return {
189+
handleUpdateRates,
190+
isUpdating,
191+
formRates,
192+
updateRate,
193+
};
194+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import currencyCodes from 'currency-codes';
2+
import { type Procedure, procedure } from '@server/trpc';
3+
import db from '@server/db';
4+
import { CreateExchangeRateInput, CreateExchangeRateOutput } from './types';
5+
6+
const createExchangeRate: Procedure<
7+
CreateExchangeRateInput,
8+
CreateExchangeRateOutput
9+
> = async ({ input: { ticker, close } }) => {
10+
console.log('ticker', ticker);
11+
console.log('close', close);
12+
const inserted = await db
13+
.insertInto('exchangeRate')
14+
.values({
15+
ticker,
16+
close,
17+
open: close,
18+
low: close,
19+
high: close,
20+
date: new Date().toISOString(),
21+
})
22+
.returningAll()
23+
.executeTakeFirstOrThrow();
24+
const data = currencyCodes.code(inserted.ticker.replace('EUR', ''));
25+
return {
26+
...inserted,
27+
code: data?.code || '',
28+
currency: data?.currency || '',
29+
};
30+
};
31+
32+
export default procedure
33+
.input(CreateExchangeRateInput)
34+
.output(CreateExchangeRateOutput)
35+
.mutation(createExchangeRate);

0 commit comments

Comments
 (0)