diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/expense-form.tsx | 390 | ||||
| -rw-r--r-- | src/components/ui/collapsible.tsx | 11 | ||||
| -rw-r--r-- | src/lib/api.ts | 45 | ||||
| -rw-r--r-- | src/lib/balances.ts | 41 | ||||
| -rw-r--r-- | src/lib/schemas.ts | 132 |
5 files changed, 458 insertions, 161 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> ) diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..9fa4894 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/lib/api.ts b/src/lib/api.ts index 98419d4..586a354 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -36,7 +36,7 @@ export async function createExpense( for (const participant of [ expenseFormValues.paidBy, - ...expenseFormValues.paidFor, + ...expenseFormValues.paidFor.map((p) => p.participant), ]) { if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) @@ -50,10 +50,12 @@ export async function createExpense( amount: expenseFormValues.amount, title: expenseFormValues.title, paidById: expenseFormValues.paidBy, + splitMode: expenseFormValues.splitMode, paidFor: { createMany: { data: expenseFormValues.paidFor.map((paidFor) => ({ - participantId: paidFor, + participantId: paidFor.participant, + shares: paidFor.shares, })), }, }, @@ -84,12 +86,14 @@ export async function getGroupExpensesParticipants(groupId: string) { export async function getGroups(groupIds: string[]) { const prisma = await getPrisma() - return (await prisma.group.findMany({ - where: { id: { in: groupIds } }, - include: { _count: { select: { participants: true } } }, - })).map(group => ({ + return ( + await prisma.group.findMany({ + where: { id: { in: groupIds } }, + include: { _count: { select: { participants: true } } }, + }) + ).map((group) => ({ ...group, - createdAt: group.createdAt.toISOString() + createdAt: group.createdAt.toISOString(), })) } @@ -106,7 +110,7 @@ export async function updateExpense( for (const participant of [ expenseFormValues.paidBy, - ...expenseFormValues.paidFor, + ...expenseFormValues.paidFor.map((p) => p.participant), ]) { if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) @@ -119,17 +123,34 @@ export async function updateExpense( amount: expenseFormValues.amount, title: expenseFormValues.title, paidById: expenseFormValues.paidBy, + splitMode: expenseFormValues.splitMode, paidFor: { - connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({ + create: expenseFormValues.paidFor + .filter( + (p) => + !existingExpense.paidFor.some( + (pp) => pp.participantId === p.participant, + ), + ) + .map((paidFor) => ({ + participantId: paidFor.participant, + shares: paidFor.shares, + })), + update: expenseFormValues.paidFor.map((paidFor) => ({ where: { - expenseId_participantId: { expenseId, participantId: paidFor }, + expenseId_participantId: { + expenseId, + participantId: paidFor.participant, + }, + }, + data: { + shares: paidFor.shares, }, - create: { participantId: paidFor }, })), deleteMany: existingExpense.paidFor.filter( (paidFor) => !expenseFormValues.paidFor.some( - (pf) => pf === paidFor.participantId, + (pf) => pf.participant === paidFor.participantId, ), ), }, diff --git a/src/lib/balances.ts b/src/lib/balances.ts index 77f4622..0bcd915 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -1,5 +1,6 @@ import { getGroupExpenses } from '@/lib/api' import { Participant } from '@prisma/client' +import { match } from 'ts-pattern' export type Balances = Record< Participant['id'], @@ -19,34 +20,42 @@ export function getBalances( for (const expense of expenses) { const paidBy = expense.paidById - const paidFors = expense.paidFor.map((p) => p.participantId) + const paidFors = expense.paidFor if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 } balances[paidBy].paid += expense.amount balances[paidBy].total += expense.amount + + const totalPaidForShares = paidFors.reduce( + (sum, paidFor) => sum + paidFor.shares, + 0, + ) + let remaining = expense.amount paidFors.forEach((paidFor, index) => { - if (!balances[paidFor]) - balances[paidFor] = { paid: 0, paidFor: 0, total: 0 } + if (!balances[paidFor.participantId]) + balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 } + + const isLast = index === paidFors.length - 1 + + const [shares, totalShares] = match(expense.splitMode) + .with('EVENLY', () => [1, paidFors.length]) + .with('BY_SHARES', () => [paidFor.shares, totalPaidForShares]) + .with('BY_PERCENTAGE', () => [paidFor.shares, totalPaidForShares]) + .with('BY_AMOUNT', () => [paidFor.shares, totalPaidForShares]) + .exhaustive() - const dividedAmount = divide( - expense.amount, - paidFors.length, - index === paidFors.length - 1, - ) - balances[paidFor].paidFor += dividedAmount - balances[paidFor].total -= dividedAmount + const dividedAmount = isLast + ? remaining + : Math.floor((expense.amount * shares) / totalShares) + remaining -= dividedAmount + balances[paidFor.participantId].paidFor += dividedAmount + balances[paidFor.participantId].total -= dividedAmount }) } return balances } -function divide(total: number, count: number, isLast: boolean): number { - if (!isLast) return Math.floor(total / count) - - return total - divide(total, count, false) * (count - 1) -} - export function getSuggestedReimbursements( balances: Balances, ): Reimbursement[] { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 0bebae6..9b7d428 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -1,3 +1,4 @@ +import { SplitMode } from '@prisma/client' import * as z from 'zod' export const groupFormSchema = z @@ -38,36 +39,111 @@ export const groupFormSchema = z export type GroupFormValues = z.infer<typeof groupFormSchema> -export const expenseFormSchema = z.object({ - title: z - .string({ required_error: 'Please enter a title.' }) - .min(2, 'Enter at least two characters.'), - amount: z - .union( - [ - z.number(), - z.string().transform((value, ctx) => { - const valueAsNumber = Number(value) - if (Number.isNaN(valueAsNumber)) +export const expenseFormSchema = z + .object({ + title: z + .string({ required_error: 'Please enter a title.' }) + .min(2, 'Enter at least two characters.'), + amount: z + .union( + [ + z.number(), + z.string().transform((value, ctx) => { + const valueAsNumber = Number(value) + if (Number.isNaN(valueAsNumber)) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid number.', + }) + return Math.round(valueAsNumber * 100) + }), + ], + { required_error: 'You must enter an amount.' }, + ) + .refine((amount) => amount >= 1, 'The amount must be higher than 0.01.') + .refine( + (amount) => amount <= 10_000_000_00, + 'The amount must be lower than 10,000,000.', + ), + paidBy: z.string({ required_error: 'You must select a participant.' }), + paidFor: z + .array( + z.object({ + participant: z.string(), + shares: z.union([ + z.number(), + z.string().transform((value, ctx) => { + const valueAsNumber = Number(value) + if (Number.isNaN(valueAsNumber)) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid number.', + }) + return Math.round(valueAsNumber * 100) + }), + ]), + }), + ) + .min(1, 'The expense must be paid for at least one participant.') + .superRefine((paidFor, ctx) => { + let sum = 0 + for (const { shares } of paidFor) { + sum += shares + if (shares < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Invalid number.', + message: 'All shares must be higher than 0.', }) - return Math.round(valueAsNumber * 100) - }), - ], - { required_error: 'You must enter an amount.' }, - ) - .refine((amount) => amount >= 1, 'The amount must be higher than 0.01.') - .refine( - (amount) => amount <= 10_000_000_00, - 'The amount must be lower than 10,000,000.', - ), - paidBy: z.string({ required_error: 'You must select a participant.' }), - paidFor: z - .array(z.string()) - .min(1, 'The expense must be paid for at least one participant.'), - isReimbursement: z.boolean(), -}) + } + } + }), + splitMode: z + .enum<SplitMode, [SplitMode, ...SplitMode[]]>( + Object.values(SplitMode) as any, + ) + .default('EVENLY'), + isReimbursement: z.boolean(), + }) + .superRefine((expense, ctx) => { + let sum = 0 + for (const { shares } of expense.paidFor) { + sum += + typeof shares === 'number' ? shares : Math.round(Number(shares) * 100) + } + switch (expense.splitMode) { + case 'EVENLY': + break // noop + case 'BY_SHARES': + break // noop + case 'BY_AMOUNT': { + if (sum !== expense.amount) { + const detail = + sum < expense.amount + ? `${((expense.amount - sum) / 100).toFixed(2)} missing` + : `${((sum - expense.amount) / 100).toFixed(2)} surplus` + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Sum of amounts must equal the expense amount (${detail}).`, + path: ['paidFor'], + }) + } + break + } + case 'BY_PERCENTAGE': { + if (sum !== 10000) { + const detail = + sum < 10000 + ? `${((10000 - sum) / 100).toFixed(0)}% missing` + : `${((sum - 10000) / 100).toFixed(0)}% surplus` + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Sum of percentages must equal 100 (${detail})`, + path: ['paidFor'], + }) + } + break + } + } + }) export type ExpenseFormValues = z.infer<typeof expenseFormSchema> |
