Skip to content

Commit

Permalink
feat: Provide dialog for creating multiple transactions at once
Browse files Browse the repository at this point in the history
  • Loading branch information
tklein1801 committed May 9, 2024
1 parent 3943a47 commit 5d69247
Show file tree
Hide file tree
Showing 9 changed files with 383 additions and 11 deletions.
37 changes: 33 additions & 4 deletions src/components/Base/FullScreenDialog.component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import {CloseRounded} from '@mui/icons-material';
import {AppBar, Box, type BoxProps, Dialog, type DialogProps, IconButton, Toolbar, Typography} from '@mui/material';
import {
AppBar,
Box,
type BoxProps,
Dialog,
DialogActions,
type DialogActionsProps,
DialogContent,
type DialogProps,
IconButton,
Toolbar,
Typography,
} from '@mui/material';
import React from 'react';

import {Transition} from '@/components/DeleteDialog.component.tsx';
Expand All @@ -9,17 +21,21 @@ export type TFullScreenDialogProps = DialogProps & {
onClose: () => void;
appBarActions?: React.ReactNode;
boxProps?: BoxProps;
dialogActionsProps?: DialogActionsProps;
wrapInDialogContent?: boolean;
};

export const FullScreenDialog: React.FC<TFullScreenDialogProps> = ({
title,
onClose,
appBarActions,
boxProps,
dialogActionsProps,
wrapInDialogContent = false,
...dialogProps
}) => {
return (
<Dialog fullScreen TransitionComponent={Transition} PaperProps={{elevation: 0}} {...dialogProps}>
const Content = (
<React.Fragment>
<AppBar
elevation={0}
sx={{position: 'relative', border: 0, borderBottom: theme => `1px solid ${theme.palette.divider}`}}>
Expand All @@ -43,9 +59,22 @@ export const FullScreenDialog: React.FC<TFullScreenDialogProps> = ({
</Toolbar>
</AppBar>

<Box {...boxProps} sx={{p: 2, ...boxProps?.sx}}>
<Box {...boxProps} sx={{p: 2, flex: 1, ...boxProps?.sx}}>
{dialogProps.children}
</Box>
</React.Fragment>
);

return (
<Dialog fullScreen TransitionComponent={Transition} PaperProps={{elevation: 0}} {...dialogProps}>
{wrapInDialogContent ? <DialogContent>{Content}</DialogContent> : Content}

{dialogActionsProps && (
<DialogActions
{...dialogActionsProps}
sx={{border: 0, borderTop: theme => `1px solid ${theme.palette.divider}`, ...dialogActionsProps.sx}}
/>
)}
</Dialog>
);
};
47 changes: 47 additions & 0 deletions src/components/Base/Menu.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {MoreVertRounded} from '@mui/icons-material';
import {
Button,
type ButtonProps,
MenuItem,
MenuItemProps,
Menu as MuiMenu,
type MenuProps as MuiMenuProps,
} from '@mui/material';
import React from 'react';

export type TMenuProps = {
buttonProps?: ButtonProps;
menuProps?: MuiMenuProps;
actions: MenuItemProps[];
};

export const Menu: React.FC<TMenuProps> = ({buttonProps, menuProps, actions}) => {
const id = React.useId();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};

return (
<React.Fragment>
<Button onClick={handleClick} children={<MoreVertRounded />} {...buttonProps} />
<MuiMenu anchorEl={anchorEl} onClose={handleClose} {...menuProps} open={open}>
{actions.map((action, idx) => (
<MenuItem
key={id + idx}
{...action}
onClick={event => {
action.onClick && action.onClick(event);
handleClose();
}}
/>
))}
</MuiMenu>
</React.Fragment>
);
};
3 changes: 2 additions & 1 deletion src/components/Base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export * from './Charts';
export * from './SparklineWidget.component';
export * from './LabelBadge.component';
export * from './TabPanel.component';
export * from './FullScreenDialog.component.tsx';
export * from './FullScreenDialog.component';
export * from './Menu.component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import {type TCreateTransactionPayload, type TTransaction, ZCreateTransactionPayload} from '@budgetbuddyde/types';
import {AddRounded, DeleteRounded} from '@mui/icons-material';
import {AutocompleteChangeReason, Box, Button, Grid, IconButton, InputAdornment, Stack, TextField} from '@mui/material';
import {DesktopDatePicker, LocalizationProvider} from '@mui/x-date-pickers';
import {AdapterDateFns} from '@mui/x-date-pickers/AdapterDateFns';
import {RecordModel} from 'pocketbase';
import React from 'react';
import {z} from 'zod';

import {useAuthContext} from '@/components/Auth';
import {
FullScreenDialog,
ReceiverAutocomplete,
type TFullScreenDialogProps,
type TReceiverAutocompleteOption,
} from '@/components/Base';
import {CategoryAutocomplete, type TCategoryAutocompleteOption} from '@/components/Category';
import {PaymentMethodAutocomplete, type TPaymentMethodAutocompleteOption} from '@/components/PaymentMethod';
import {useSnackbarContext} from '@/components/Snackbar';
import {useKeyPress} from '@/hooks';
import {parseNumber} from '@/utils';

import {TransactionService} from './Transaction.service';
import {type TTransactionDrawerValues} from './TransactionDrawer.component';
import {useFetchTransactions} from './useFetchTransactions.hook';

export type TCreateMultipleTransactionsDialogProps = Omit<TFullScreenDialogProps, 'title'>;

type TRow = Omit<TTransactionDrawerValues, 'transfer_amount'> & {
tempId: number;
transfer_amount: TTransaction['transfer_amount'] | string | undefined;
};

const DEFAULT_VALUE: () => TRow = () => ({
tempId: Date.now(),
processed_at: new Date(),
category: null,
payment_method: null,
receiver: null,
transfer_amount: undefined,
information: '',
});

export const CreateMultipleTransactionsDialog: React.FC<TCreateMultipleTransactionsDialogProps> = ({
...dialogProps
}) => {
const {sessionUser} = useAuthContext();
const {showSnackbar} = useSnackbarContext();
const {refresh: refreshTransactions} = useFetchTransactions();
const dialogRef = React.useRef<HTMLDivElement>(null);
const [form, setForm] = React.useState<TRow[]>([DEFAULT_VALUE()]);

const handler = {
close: () => {
setForm([DEFAULT_VALUE()]);
dialogProps.onClose();
},
addRow: () => setForm(prev => [...prev, DEFAULT_VALUE()]),
removeRow: (id: number) => setForm(prev => prev.filter(item => item.tempId !== id)),
changeDate: (idx: number, value: Date | null, _keyboardInputValue?: string | undefined) => {
setForm(prev => {
const newForm = [...prev];
newForm[idx].processed_at = value ?? new Date();
return newForm;
});
},
changeCategory: (
idx: number,
_event: React.SyntheticEvent<Element, Event>,
value: TCategoryAutocompleteOption | null,
_reason: AutocompleteChangeReason,
) => {
setForm(prev => {
const newForm = [...prev];
newForm[idx].category = value;
return newForm;
});
},
changePaymentMethod: (
idx: number,
_event: React.SyntheticEvent<Element, Event>,
value: TPaymentMethodAutocompleteOption | null,
_reason: AutocompleteChangeReason,
) => {
setForm(prev => {
const newForm = [...prev];
newForm[idx].payment_method = value;
return newForm;
});
},
changeReceiver: (
idx: number,
_event: React.SyntheticEvent<Element, Event>,
value: TReceiverAutocompleteOption | null,
_reason: AutocompleteChangeReason,
) => {
setForm(prev => {
const newForm = [...prev];
newForm[idx].receiver = value;
return newForm;
});
},
changeTransferAmount: (idx: number, value: number | string) => {
setForm(prev => {
const newForm = [...prev];
newForm[idx].transfer_amount = value;
return newForm;
});
},
changeInformation: (idx: number, value: string) => {
setForm(prev => {
const newForm = [...prev];
newForm[idx].information = value;
return newForm;
});
},
onSubmit: async () => {
try {
const parsedForm = z.array(ZCreateTransactionPayload).safeParse(
form.map(row => ({
processed_at: row.processed_at,
category: row.category?.id,
payment_method: row.payment_method?.id,
receiver: row.receiver?.value,
transfer_amount: parseNumber(String(row.transfer_amount)),
owner: sessionUser?.id,
information: row.information,
})),
);
if (!parsedForm.success) {
throw parsedForm.error;
}
const payload: TCreateTransactionPayload[] = parsedForm.data;
const submittedPromises = await Promise.allSettled(payload.map(TransactionService.createTransaction));
const createdTransactions: RecordModel[] = submittedPromises
.map(promise => (promise.status == 'fulfilled' ? promise.value : null))
.filter(value => value !== null) as RecordModel[];

if (createdTransactions.length === 0) {
throw new Error('No transactions were created');
}

handler.close();
React.startTransition(() => {
refreshTransactions();
});
showSnackbar({
message:
createdTransactions.length === 1
? `Created transaction #${createdTransactions[0].id}`
: `Created ${createdTransactions.length} transactions`,
});
} catch (error) {
console.error(error);
showSnackbar({
message: 'Error while submitting the forms',
action: (
<Button size="small" onClick={handler.onSubmit}>
Retry
</Button>
),
});
}
},
};

useKeyPress(
['s'],
e => {
e.preventDefault();
handler.onSubmit();
},
null,
true,
);

return (
<FullScreenDialog
ref={dialogRef}
title="Create Transactions"
wrapInDialogContent
{...dialogProps}
dialogActionsProps={{
sx: {justifyContent: 'unset'},
children: (
<Stack direction="row" spacing={2} sx={{width: '100%', justifyContent: 'space-between'}}>
<Button startIcon={<AddRounded />} onClick={handler.addRow}>
Add row
</Button>
<Box>
<Button onClick={handler.close} sx={{mr: 1}}>
Cancel
</Button>
<Button onClick={handler.onSubmit} variant="contained" color="primary">
Save
</Button>
</Box>
</Stack>
),
}}>
<form onSubmit={handler.onSubmit}>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Grid container spacing={2}>
{form.map((row, idx) => (
<Grid key={row.tempId} container item md={12} spacing={2}>
{idx !== 0 && (
<Grid item xs={0.6} md={0.55}>
<IconButton
onClick={() => handler.removeRow(row.tempId)}
size="large"
sx={{width: '54px', height: '54px'}}>
<DeleteRounded />
</IconButton>
</Grid>
)}
<Grid item md={idx === 0 ? 2 : 1.45}>
<DesktopDatePicker
label="Processed at"
inputFormat="dd.MM.yyyy"
onChange={(value, keyboardInputValue) => handler.changeDate(idx, value, keyboardInputValue)}
value={row.processed_at}
renderInput={params => <TextField fullWidth {...params} required />}
/>
</Grid>
<Grid item md={2}>
<CategoryAutocomplete
value={row.category}
onChange={(event, value, reason) => handler.changeCategory(idx, event, value, reason)}
/>
</Grid>
<Grid item md={2}>
<PaymentMethodAutocomplete
value={row.payment_method}
onChange={(event, value, reason) => handler.changePaymentMethod(idx, event, value, reason)}
/>
</Grid>
<Grid item xs md={2}>
<ReceiverAutocomplete
value={row.receiver}
onChange={(event, value, reason) => handler.changeReceiver(idx, event, value, reason)}
/>
</Grid>
<Grid item md={2}>
<TextField
label="Amount"
value={row.transfer_amount}
onChange={e => handler.changeTransferAmount(idx, e.target.value)}
InputProps={{startAdornment: <InputAdornment position="start"></InputAdornment>}}
required
fullWidth
/>
</Grid>
<Grid item md={2}>
<TextField
label="Information"
value={row.information}
onChange={event => handler.changeInformation(idx, event.target.value)}
fullWidth
multiline
/>
</Grid>
</Grid>
))}
</Grid>
</LocalizationProvider>
</form>
</FullScreenDialog>
);
};
Loading

0 comments on commit 5d69247

Please sign in to comment.