'use client'; import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Loader2, Upload, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; import { CurrencyInput } from '@/components/shared/currency-input'; import { CurrencySelect } from '@/components/shared/currency-select'; import { TripLabelCombobox } from '@/components/expenses/trip-label-combobox'; import { apiFetch } from '@/lib/api/client'; import type { z } from 'zod'; import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; import type { ExpenseRow } from './expense-columns'; interface UploadedReceipt { id: string; filename: string; } interface ExpenseFormDialogProps { open: boolean; onOpenChange: (open: boolean) => void; expense?: ExpenseRow; } export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDialogProps) { const queryClient = useQueryClient(); const isEdit = !!expense; const fileInputRef = useRef(null); const [uploadedReceipt, setUploadedReceipt] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [noReceipt, setNoReceipt] = useState(false); const [uploadError, setUploadError] = useState(null); const [isUploading, setIsUploading] = useState(false); const { register, handleSubmit, setValue, reset, watch, formState: { errors, isSubmitting }, } = useForm, unknown, CreateExpenseInput>({ resolver: zodResolver(createExpenseSchema), defaultValues: { currency: 'USD', paymentStatus: 'unpaid', }, }); useEffect(() => { if (open && expense) { reset({ establishmentName: expense.establishmentName ?? undefined, amount: Number(expense.amount), currency: expense.currency, category: expense.category as CreateExpenseInput['category'], paymentMethod: expense.paymentMethod as CreateExpenseInput['paymentMethod'], payer: expense.payer ?? undefined, expenseDate: new Date(expense.expenseDate), paymentStatus: (expense.paymentStatus as CreateExpenseInput['paymentStatus']) ?? 'unpaid', tripLabel: expense.tripLabel ?? undefined, }); setUploadedReceipt(null); setPreviewUrl(null); setNoReceipt(Boolean(expense.noReceiptAcknowledged)); setUploadError(null); } else if (open && !expense) { reset({ currency: 'USD', paymentStatus: 'unpaid', expenseDate: new Date(), }); setUploadedReceipt(null); setPreviewUrl(null); setNoReceipt(false); setUploadError(null); } }, [open, expense, reset]); // Capture the URL inside the effect closure so the cleanup revokes the // URL it observed at mount, not the one captured by a later render. // Audit caught a bug where the cleanup ran on every change and revoked // the URL that was still being shown. useEffect(() => { const url = previewUrl; return () => { if (url) URL.revokeObjectURL(url); }; }, [previewUrl]); // Reset upload state whenever the sheet closes — re-opening on the same // expense was carrying stale state from the prior session. useEffect(() => { if (!open) { setUploadedReceipt(null); setPreviewUrl(null); setNoReceipt(false); setUploadError(null); setIsUploading(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }, [open]); const mutation = useMutation({ mutationFn: (data: CreateExpenseInput) => { if (isEdit) { return apiFetch(`/api/v1/expenses/${expense.id}`, { method: 'PATCH', body: data, }); } return apiFetch('/api/v1/expenses', { method: 'POST', body: data }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['expenses'] }); onOpenChange(false); }, }); async function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setUploadError(null); if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(URL.createObjectURL(file)); setIsUploading(true); try { const formData = new FormData(); formData.append('file', file); formData.append('category', 'receipt'); const res = await fetch('/api/v1/files/upload', { method: 'POST', body: formData, credentials: 'include', }); if (!res.ok) throw new Error('Upload failed'); const json = (await res.json()) as { data: { id: string; filename: string } }; setUploadedReceipt({ id: json.data.id, filename: json.data.filename }); setNoReceipt(false); } catch (err) { setUploadError(err instanceof Error ? err.message : 'Upload failed'); setUploadedReceipt(null); } finally { setIsUploading(false); } } function clearReceipt() { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setUploadedReceipt(null); setUploadError(null); if (fileInputRef.current) fileInputRef.current.value = ''; } function onSubmit(data: CreateExpenseInput) { mutation.mutate({ ...data, receiptFileIds: uploadedReceipt ? [uploadedReceipt.id] : undefined, noReceiptAcknowledged: Boolean(noReceipt && !uploadedReceipt), }); } const canSubmit = isEdit || Boolean(uploadedReceipt) || noReceipt; return ( {isEdit ? 'Edit Expense' : 'New Expense'}
(v ? new Date(v) : undefined), })} defaultValue={ expense?.expenseDate ? new Date(expense.expenseDate).toISOString().split('T')[0] : new Date().toISOString().split('T')[0] } /> {errors.expenseDate && (

{errors.expenseDate.message}

)}
setValue('amount', v ?? Number.NaN, { shouldDirty: true, shouldValidate: true }) } /> {errors.amount &&

{errors.amount.message}

}
setValue('currency', v, { shouldDirty: true })} /> {errors.currency && (

{errors.currency.message}

)}
setValue('tripLabel', label ?? undefined, { shouldDirty: true })} placeholder="e.g. Palm Beach 2026 (optional)" />

Group expenses by yacht show or business trip. Pick a past trip or type a new one.