diff options
Diffstat (limited to 'src/components/expense-form.tsx')
| -rw-r--r-- | src/components/expense-form.tsx | 390 |
1 files changed, 285 insertions, 105 deletions
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index bdee9d0..af3a923 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -5,12 +5,17 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, - CardFooter, + CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import { Form, FormControl, FormDescription, @@ -29,9 +34,12 @@ import { } from '@/components/ui/select' import { getExpense, getGroup } from '@/lib/api' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' +import { cn } from '@/lib/utils' import { zodResolver } from '@hookform/resolvers/zod' +import { Save, Trash2 } from 'lucide-react' import { useSearchParams } from 'next/navigation' import { useForm } from 'react-hook-form' +import { match } from 'ts-pattern' export type Props = { group: NonNullable<Awaited<ReturnType<typeof getGroup>>> @@ -50,7 +58,11 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { title: expense.title, amount: String(expense.amount / 100) as unknown as number, // hack paidBy: expense.paidById, - paidFor: expense.paidFor.map(({ participantId }) => participantId), + paidFor: expense.paidFor.map(({ participantId, shares }) => ({ + participant: participantId, + shares: String(shares / 100) as unknown as number, + })), + splitMode: expense.splitMode, isReimbursement: expense.isReimbursement, } : searchParams.get('reimbursement') @@ -60,10 +72,20 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { (Number(searchParams.get('amount')) || 0) / 100, ) as unknown as number, // hack paidBy: searchParams.get('from') ?? undefined, - paidFor: [searchParams.get('to') ?? undefined], + paidFor: [ + searchParams.get('to') + ? { participant: searchParams.get('to')! } + : undefined, + ], isReimbursement: true, } - : { title: '', amount: 0, paidFor: [], isReimbursement: false }, + : { + title: '', + amount: 0, + paidFor: [], + isReimbursement: false, + splitMode: 'EVENLY', + }, }) return ( @@ -75,12 +97,12 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { {isCreate ? <>Create expense</> : <>Edit expense</>} </CardTitle> </CardHeader> - <CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6"> + <CardContent className="grid sm:grid-cols-2 gap-6"> <FormField control={form.control} name="title" render={({ field }) => ( - <FormItem className="order-1"> + <FormItem className=""> <FormLabel>Expense title</FormLabel> <FormControl> <Input @@ -99,38 +121,9 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { <FormField control={form.control} - name="paidBy" - render={({ field }) => ( - <FormItem className="order-3 sm:order-2"> - <FormLabel>Paid by</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <SelectTrigger> - <SelectValue placeholder="Select a participant" /> - </SelectTrigger> - <SelectContent> - {group.participants.map(({ id, name }) => ( - <SelectItem key={id} value={id}> - {name} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormDescription> - Select the participant who paid the expense. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} name="amount" render={({ field }) => ( - <FormItem className="order-2 sm:order-3"> + <FormItem className="sm:order-3"> <FormLabel>Amount</FormLabel> <div className="flex items-baseline gap-2"> <span>{group.currency}</span> @@ -170,42 +163,80 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { <FormField control={form.control} + name="paidBy" + render={({ field }) => ( + <FormItem className="sm:order-5"> + <FormLabel>Paid by</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select a participant" /> + </SelectTrigger> + <SelectContent> + {group.participants.map(({ id, name }) => ( + <SelectItem key={id} value={id}> + {name} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + Select the participant who paid the expense. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + <Card className="mt-4"> + <CardHeader> + <CardTitle className="flex justify-between"> + <span>Paid for</span> + <Button + variant="link" + type="button" + className="-my-2 -mx-4" + onClick={() => { + const paidFor = form.getValues().paidFor + const allSelected = + paidFor.length === group.participants.length + const newPaidFor = allSelected + ? [] + : group.participants.map((p) => ({ + participant: p.id, + shares: + paidFor.find((pfor) => pfor.participant === p.id) + ?.shares ?? ('1' as unknown as number), + })) + form.setValue('paidFor', newPaidFor, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + }} + > + {form.getValues().paidFor.length === + group.participants.length ? ( + <>Select none</> + ) : ( + <>Select all</> + )} + </Button> + </CardTitle> + <CardDescription> + Select who the expense was paid for. + </CardDescription> + </CardHeader> + <CardContent> + <FormField + control={form.control} name="paidFor" render={() => ( - <FormItem className="order-5"> - <div className="mb-4"> - <FormLabel> - Paid for - <Button - variant="link" - type="button" - className="-m-2" - onClick={() => { - const paidFor = form.getValues().paidFor - const allSelected = - paidFor.length === group.participants.length - const newPairFor = allSelected - ? [] - : group.participants.map((p) => p.id) - form.setValue('paidFor', newPairFor, { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }) - }} - > - {form.getValues().paidFor.length === - group.participants.length ? ( - <>Select none</> - ) : ( - <>Select all</> - )} - </Button> - </FormLabel> - <FormDescription> - Select who the expense was paid for. - </FormDescription> - </div> + <FormItem className="sm:order-4 row-span-2 space-y-0"> {group.participants.map(({ id, name }) => ( <FormField key={id} @@ -213,28 +244,123 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { name="paidFor" render={({ field }) => { return ( - <FormItem - key={id} - className="flex flex-row items-start space-x-3 space-y-0" + <div + data-id={`${id}/${form.getValues().splitMode}/${ + group.currency + }`} + className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3" > - <FormControl> - <Checkbox - checked={field.value?.includes(id)} - onCheckedChange={(checked) => { - return checked - ? field.onChange([...field.value, id]) - : field.onChange( - field.value?.filter( - (value) => value !== id, + <FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value?.some( + ({ participant }) => participant === id, + )} + onCheckedChange={(checked) => { + return checked + ? field.onChange([ + ...field.value, + { + participant: id, + shares: '1', + }, + ]) + : field.onChange( + field.value?.filter( + (value) => value.participant !== id, + ), + ) + }} + /> + </FormControl> + <FormLabel className="text-sm font-normal flex-1"> + {name} + </FormLabel> + </FormItem> + {form.getValues().splitMode !== 'EVENLY' && ( + <FormField + name={`paidFor[${field.value.findIndex( + ({ participant }) => participant === id, + )}].shares`} + render={() => { + const sharesLabel = ( + <span + className={cn('text-sm', { + 'text-muted': !field.value?.some( + ({ participant }) => + participant === id, ), - ) + })} + > + {match(form.getValues().splitMode) + .with('BY_SHARES', () => <>share(s)</>) + .with('BY_PERCENTAGE', () => <>%</>) + .with('BY_AMOUNT', () => ( + <>{group.currency}</> + )) + .otherwise(() => ( + <></> + ))} + </span> + ) + return ( + <div> + <div className="flex gap-1 items-baseline"> + {form.getValues().splitMode === + 'BY_AMOUNT' && sharesLabel} + <FormControl> + <Input + key={String( + !field.value?.some( + ({ participant }) => + participant === id, + ), + )} + className="text-base w-[80px] -my-2" + type="number" + disabled={ + !field.value?.some( + ({ participant }) => + participant === id, + ) + } + value={ + field.value?.find( + ({ participant }) => + participant === id, + )?.shares + } + onChange={(event) => + field.onChange( + field.value.map((p) => + p.participant === id + ? { + participant: id, + shares: + event.target.value, + } + : p, + ), + ) + } + inputMode="numeric" + step={1} + /> + </FormControl> + {[ + 'BY_SHARES', + 'BY_PERCENTAGE', + ].includes( + form.getValues().splitMode, + ) && sharesLabel} + </div> + <FormMessage className="float-right" /> + </div> + ) }} /> - </FormControl> - <FormLabel className="text-sm font-normal"> - {name} - </FormLabel> - </FormItem> + )} + </div> ) }} /> @@ -243,26 +369,80 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { </FormItem> )} /> + + <Collapsible className="mt-5"> + <CollapsibleTrigger asChild> + <Button variant="link" className="-mx-4"> + Advanced splitting options… + </Button> + </CollapsibleTrigger> + <CollapsibleContent> + <div className="grid sm:grid-cols-2 gap-6 pt-3"> + <FormField + control={form.control} + name="splitMode" + render={({ field }) => ( + <FormItem className="sm:order-2"> + <FormLabel>Split mode</FormLabel> + <FormControl> + <Select + onValueChange={(value) => { + form.setValue('splitMode', value as any, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + }} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EVENLY">Evenly</SelectItem> + <SelectItem value="BY_SHARES"> + Unevenly – By shares + </SelectItem> + <SelectItem value="BY_PERCENTAGE"> + Unevenly – By percentage + </SelectItem> + <SelectItem value="BY_AMOUNT"> + Unevenly – By amount + </SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormDescription> + Select how to split the expense. + </FormDescription> + </FormItem> + )} + /> + </div> + </CollapsibleContent> + </Collapsible> </CardContent> + </Card> - <CardFooter className="gap-2"> - <SubmitButton - loadingContent={isCreate ? <>Creating…</> : <>Saving…</>} + <div className="flex mt-4 gap-2"> + <SubmitButton + loadingContent={isCreate ? <>Creating…</> : <>Saving…</>} + > + <Save className="w-4 h-4 mr-2" /> + {isCreate ? <>Create</> : <>Save</>} + </SubmitButton> + {!isCreate && onDelete && ( + <AsyncButton + type="button" + variant="destructive" + loadingContent="Deleting…" + action={onDelete} > - {isCreate ? <>Create</> : <>Save</>} - </SubmitButton> - {!isCreate && onDelete && ( - <AsyncButton - type="button" - variant="destructive" - loadingContent="Deleting…" - action={onDelete} - > - Delete - </AsyncButton> - )} - </CardFooter> - </Card> + <Trash2 className="w-4 h-4 mr-2" /> + Delete + </AsyncButton> + )} + </div> </form> </Form> ) |
