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 { randomId } from '@/lib/api' import { RuntimeFeatureFlags } from '@/lib/featureFlags' import { useActiveUser } from '@/lib/hooks' import { ExpenseFormValues, SplittingOptions, expenseFormSchema, } from '@/lib/schemas' import { calculateShare } from '@/lib/totals' import { cn } from '@/lib/utils' import { AppRouterOutput } from '@/trpc/routers/_app' import { zodResolver } from '@hookform/resolvers/zod' import { Save } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' import { useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { DeletePopup } from '../../../../components/delete-popup' import { extractCategoryFromTitle } from '../../../../components/expense-form-actions' import { Textarea } from '../../../../components/ui/textarea' import { RecurrenceRule } from '@prisma/client' const enforceCurrencyPattern = (value: string) => value .replace(/^\s*-/, '_') // replace leading minus with _ .replace(/[.,]/, '#') // replace first comma with # .replace(/[-.,]/g, '') // remove other minus and commas characters .replace(/_/, '-') // change back _ to minus .replace(/#/, '.') // change back # to dot .replace(/[^-\d.]/g, '') // remove all non-numeric characters const getDefaultSplittingOptions = ( group: NonNullable, ) => { const defaultValue = { splitMode: 'EVENLY' as const, paidFor: group.participants.map(({ id }) => ({ participant: id, shares: '1' as unknown as number, })), } if (typeof localStorage === 'undefined') return defaultValue const defaultSplitMode = localStorage.getItem( `${group.id}-defaultSplittingOptions`, ) if (defaultSplitMode === null) return defaultValue const parsedDefaultSplitMode = JSON.parse( defaultSplitMode, ) as SplittingOptions if (parsedDefaultSplitMode.paidFor === null) { parsedDefaultSplitMode.paidFor = defaultValue.paidFor } // if there is a participant in the default options that does not exist anymore, // remove the stale default splitting options for (const parsedPaidFor of parsedDefaultSplitMode.paidFor) { if ( !group.participants.some(({ id }) => id === parsedPaidFor.participant) ) { localStorage.removeItem(`${group.id}-defaultSplittingOptions`) return defaultValue } } return { splitMode: parsedDefaultSplitMode.splitMode, paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({ participant: paidFor.participant, shares: String(paidFor.shares / 100) as unknown as number, })), } } async function persistDefaultSplittingOptions( groupId: string, expenseFormValues: ExpenseFormValues, ) { if (localStorage && expenseFormValues.saveDefaultSplittingOptions) { const computePaidFor = (): SplittingOptions['paidFor'] => { if (expenseFormValues.splitMode === 'EVENLY') { return expenseFormValues.paidFor.map(({ participant }) => ({ participant, shares: '100' as unknown as number, })) } else if (expenseFormValues.splitMode === 'BY_AMOUNT') { return null } else { return expenseFormValues.paidFor } } const splittingOptions = { splitMode: expenseFormValues.splitMode, paidFor: computePaidFor(), } satisfies SplittingOptions localStorage.setItem( `${groupId}-defaultSplittingOptions`, JSON.stringify(splittingOptions), ) } } export function ExpenseForm({ group, categories, expense, onSubmit, onDelete, runtimeFeatureFlags, }: { group: NonNullable categories: AppRouterOutput['categories']['list']['categories'] expense?: AppRouterOutput['groups']['expenses']['get']['expense'] onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise onDelete?: (participantId?: string) => Promise runtimeFeatureFlags: RuntimeFeatureFlags }) { const t = useTranslations('ExpenseForm') 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' && field?.value === undefined) { return activeUser } } return field?.value } const getSelectedRecurrenceRule = (field?: { value: string }) => { return field?.value as RecurrenceRule } const defaultSplittingOptions = getDefaultSplittingOptions(group) 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, saveDefaultSplittingOptions: false, isReimbursement: expense.isReimbursement, documents: expense.documents, notes: expense.notes ?? '', recurrenceRule: expense.recurrenceRule, } : searchParams.get('reimbursement') ? { title: t('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' as unknown as number, } : undefined, ], isReimbursement: true, splitMode: defaultSplittingOptions.splitMode, saveDefaultSplittingOptions: false, documents: [], notes: '', recurrenceRule: RecurrenceRule.NONE, } : { 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: defaultSplittingOptions.paidFor, paidBy: getSelectedPayer(), isReimbursement: false, splitMode: defaultSplittingOptions.splitMode, saveDefaultSplittingOptions: false, documents: searchParams.get('imageUrl') ? [ { id: randomId(), url: searchParams.get('imageUrl') as string, width: Number(searchParams.get('imageWidth')), height: Number(searchParams.get('imageHeight')), }, ] : [], notes: '', recurrenceRule: RecurrenceRule.NONE, }, }) const [isCategoryLoading, setCategoryLoading] = useState(false) const activeUserId = useActiveUser(group.id) const submit = async (values: ExpenseFormValues) => { await persistDefaultSplittingOptions(group.id, values) return onSubmit(values, activeUserId ?? undefined) } const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0) const [manuallyEditedParticipants, setManuallyEditedParticipants] = useState< Set >(new Set()) const sExpense = isIncome ? 'Income' : 'Expense' useEffect(() => { setManuallyEditedParticipants(new Set()) }, [form.watch('splitMode'), form.watch('amount')]) useEffect(() => { const splitMode = form.getValues().splitMode // Only auto-balance for split mode 'Unevenly - By amount' if ( splitMode === 'BY_AMOUNT' && (form.getFieldState('paidFor').isDirty || form.getFieldState('amount').isDirty) ) { const totalAmount = Number(form.getValues().amount) || 0 const paidFor = form.getValues().paidFor let newPaidFor = [...paidFor] const editedParticipants = Array.from(manuallyEditedParticipants) let remainingAmount = totalAmount let remainingParticipants = newPaidFor.length - editedParticipants.length newPaidFor = newPaidFor.map((participant) => { if (editedParticipants.includes(participant.participant)) { const participantShare = Number(participant.shares) || 0 if (splitMode === 'BY_AMOUNT') { remainingAmount -= participantShare } return participant } return participant }) if (remainingParticipants > 0) { let amountPerRemaining = 0 if (splitMode === 'BY_AMOUNT') { amountPerRemaining = remainingAmount / remainingParticipants } newPaidFor = newPaidFor.map((participant) => { if (!editedParticipants.includes(participant.participant)) { return { ...participant, shares: String( Number(amountPerRemaining.toFixed(2)), ) as unknown as number, } } return participant }) } form.setValue('paidFor', newPaidFor, { shouldValidate: true }) } }, [ manuallyEditedParticipants, form.watch('amount'), form.watch('splitMode'), ]) return (
{t(`${sExpense}.${isCreate ? 'create' : 'edit'}`)} ( {t(`${sExpense}.TitleField.label`)} { field.onBlur() // avoid skipping other blur event listeners since we overwrite `field` if (runtimeFeatureFlags.enableCategoryExtract) { setCategoryLoading(true) const { categoryId } = await extractCategoryFromTitle( field.value, ) form.setValue('category', categoryId) setCategoryLoading(false) } }} /> {t(`${sExpense}.TitleField.description`)} )} /> ( {t(`${sExpense}.DateField.label`)} { return field.onChange(new Date(event.target.value)) }} /> {t(`${sExpense}.DateField.description`)} )} /> ( {t('amountField.label')}
{group.currency} { const v = enforceCurrencyPattern(event.target.value) const income = Number(v) < 0 setIsIncome(income) if (income) form.setValue('isReimbursement', false) onChange(v) }} onFocus={(e) => { // we're adding a small delay to get around safaris issue with onMouseUp deselecting things again const target = e.currentTarget setTimeout(() => target.select(), 1) }} {...field} />
{!isIncome && ( (
{t('isReimbursementField.label')}
)} /> )}
)} /> ( {t('categoryField.label')} {t(`${sExpense}.categoryFieldDescription`)} )} /> ( {t(`${sExpense}.paidByField.label`)} {t(`${sExpense}.paidByField.description`)} )} /> ( {t('notesField.label')}