'use client' import { AsyncButton } from '@/components/async-button' import { CategorySelector } from '@/components/category-selector' import { ExpenseDocumentsInput } from '@/components/expense-documents-input' import { SubmitButton } from '@/components/submit-button' import { Button } from '@/components/ui/button' import { Card, CardContent, 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, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { getCategories, getExpense, getGroup, randomId } 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 { useState } from 'react' import { useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { extractCategoryFromTitle } from './expense-form-actions' export type Props = { group: NonNullable>> expense?: NonNullable>> categories: NonNullable>> onSubmit: (values: ExpenseFormValues) => Promise onDelete?: () => Promise } export function ExpenseForm({ group, expense, categories, onSubmit, onDelete, }: Props) { const isCreate = expense === undefined const searchParams = useSearchParams() const getSelectedPayer = (field?: { value: string }) => { if (isCreate && typeof window !== 'undefined') { const activeUser = localStorage.getItem(`${group.id}-activeUser`) if (activeUser && activeUser !== 'None') { return activeUser } } return field?.value } const form = useForm({ resolver: zodResolver(expenseFormSchema), defaultValues: expense ? { title: expense.title, expenseDate: expense.expenseDate ?? new Date(), amount: String(expense.amount / 100) as unknown as number, // hack category: expense.categoryId, paidBy: expense.paidById, paidFor: expense.paidFor.map(({ participantId, shares }) => ({ participant: participantId, shares: String(shares / 100) as unknown as number, })), splitMode: expense.splitMode, isReimbursement: expense.isReimbursement, documents: expense.documents, } : searchParams.get('reimbursement') ? { title: 'Reimbursement', expenseDate: new Date(), amount: String( (Number(searchParams.get('amount')) || 0) / 100, ) as unknown as number, // hack category: 1, // category with Id 1 is Payment paidBy: searchParams.get('from') ?? undefined, paidFor: [ searchParams.get('to') ? { participant: searchParams.get('to')!, shares: 1 } : undefined, ], isReimbursement: true, splitMode: 'EVENLY', documents: [], } : { title: searchParams.get('title') ?? '', expenseDate: searchParams.get('date') ? new Date(searchParams.get('date') as string) : new Date(), amount: (searchParams.get('amount') || 0) as unknown as number, // hack, category: searchParams.get('categoryId') ? Number(searchParams.get('categoryId')) : 0, // category with Id 0 is General // paid for all, split evenly paidFor: group.participants.map(({ id }) => ({ participant: id, shares: 1, })), paidBy: getSelectedPayer(), isReimbursement: false, splitMode: 'EVENLY', documents: searchParams.get('imageUrl') ? [ { id: randomId(), url: searchParams.get('imageUrl') as string, width: Number(searchParams.get('imageWidth')), height: Number(searchParams.get('imageHeight')), }, ] : [], }, }) const [isCategoryLoading, setCategoryLoading] = useState(false) return (
onSubmit(values))}> {isCreate ? <>Create expense : <>Edit expense} ( Expense title { field.onBlur() // avoid skipping other blur event listeners since we overwrite `field` if (process.env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT) { setCategoryLoading(true) const { categoryId } = await extractCategoryFromTitle( field.value, ) form.setValue('category', categoryId) setCategoryLoading(false) } }} /> Enter a description for the expense. )} /> ( Expense date { return field.onChange(new Date(event.target.value)) }} /> Enter the date the expense was made. )} /> ( Amount
{group.currency}
(
This is a reimbursement
)} />
)} /> ( Category Select the expense category. )} /> ( Paid by Select the participant who paid the expense. )} />
Paid for Select who the expense was paid for. ( {group.participants.map(({ id, name }) => ( { return (
participant === id, )} onCheckedChange={(checked) => { return checked ? field.onChange([ ...field.value, { participant: id, shares: '1', }, ]) : field.onChange( field.value?.filter( (value) => value.participant !== id, ), ) }} /> {name} {form.getValues().splitMode !== 'EVENLY' && ( participant === id, )}].shares`} render={() => { const sharesLabel = ( participant === id, ), })} > {match(form.getValues().splitMode) .with('BY_SHARES', () => <>share(s)) .with('BY_PERCENTAGE', () => <>%) .with('BY_AMOUNT', () => ( <>{group.currency} )) .otherwise(() => ( <> ))} ) return (
{form.getValues().splitMode === 'BY_AMOUNT' && sharesLabel} 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={ form.getValues().splitMode === 'BY_AMOUNT' ? 'decimal' : 'numeric' } step={ form.getValues().splitMode === 'BY_AMOUNT' ? 0.01 : 1 } /> {[ 'BY_SHARES', 'BY_PERCENTAGE', ].includes( form.getValues().splitMode, ) && sharesLabel}
) }} /> )}
) }} /> ))}
)} />
( Split mode Select how to split the expense. )} />
{process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && ( Attach documents See and attach receipts to the expense. ( )} /> )}
Creating… : <>Saving…} > {isCreate ? <>Create : <>Save} {!isCreate && onDelete && ( Delete )}
) } function formatDate(date?: Date) { if (!date || isNaN(date as any)) date = new Date() return date.toISOString().substring(0, 10) }