"use client"; import { CalendarDays, Plus, X } from "lucide-react"; import { type FormEvent, type ReactNode, useEffect, useMemo, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; import { createInvoice, formatMoney, type Invoice, type InvoiceLineItem, type InvoiceStatus, updateInvoice, } from "@/lib/invoices"; import { listPatients, type Patient } from "@/lib/patients"; import { notify } from "@/lib/toast"; import { useWalletSync } from "@/components/wallet/use-wallet-sync"; import { DialogStepper, WalletSyncStep, } from "@/components/wallet/wallet-sync-step"; const STATUSES: InvoiceStatus[] = ["draft", "sent", "paid", "void"]; const controlClass = "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; const keyOf = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( d.getDate(), ).padStart(2, "0")}`; function emptyLine(): InvoiceLineItem { return { description: "", quantity: 1, unitPrice: 0 }; } function Field({ label, children }: { label: string; children: ReactNode }) { return ( ); } // Local start-of-day, used to disable days strictly before today. const startOfToday = () => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }; function DatePicker({ value, onChange, allowPast = true, }: { value: Date; onChange: (d: Date) => void; // When false, days before today are disabled (used for the issue date unless // the clinician opts into back-dating an older invoice). allowPast?: boolean; }) { const [open, setOpen] = useState(false); return ( {value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", })} } /> { if (d) { onChange(d); setOpen(false); } }} selected={value} /> ); } // Create or edit an invoice. The patient is chosen with a searchable combobox on // create (locked on edit); line items are edited inline and the total updates // live. Persists through the invoices API and hands the saved record back. export function InvoiceFormDialog({ open, onOpenChange, mode, invoice, onSaved, }: { open: boolean; onOpenChange: (open: boolean) => void; mode: "create" | "edit"; invoice?: Invoice; onSaved: (invoice: Invoice) => void; }) { const { t } = useTranslation(); const [patients, setPatients] = useState([]); const [selected, setSelected] = useState(null); // In edit mode the patient is fixed; keep the denormalized identity. const fixedPatient = mode === "edit" && invoice ? { fileNumber: invoice.fileNumber, name: invoice.name, initials: invoice.initials, } : null; const [issuedAt, setIssuedAt] = useState(() => new Date()); // Off by default: the issue date can't be back-dated unless the clinician // opts in (for recording an older, pre-existing invoice). const [allowBackdate, setAllowBackdate] = useState(false); const [hasDue, setHasDue] = useState(false); const [dueAt, setDueAt] = useState(() => new Date()); const [status, setStatus] = useState("draft"); const [notes, setNotes] = useState(""); const [lineItems, setLineItems] = useState([emptyLine()]); const [busy, setBusy] = useState(false); const [step, setStep] = useState<"form" | "wallet">("form"); const [walletSummary, setWalletSummary] = useState(""); const activePatient = fixedPatient ?? selected; const sync = useWalletSync(activePatient?.fileNumber ?? null); const handleOpenChange = (next: boolean) => { if (!next) { setStep("form"); sync.reset(); } onOpenChange(next); }; // Seed the form when opening. useEffect(() => { if (!open) return; if (mode === "edit" && invoice) { setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`)); // Existing invoices legitimately carry past issue dates. setAllowBackdate(true); setHasDue(Boolean(invoice.dueAt)); setDueAt(new Date(`${invoice.dueAt ?? invoice.issuedAt}T00:00:00`)); setStatus(invoice.status); setNotes(invoice.notes ?? ""); setLineItems( invoice.lineItems.length ? invoice.lineItems : [emptyLine()], ); } else { setSelected(null); setIssuedAt(new Date()); setAllowBackdate(false); setHasDue(false); setDueAt(new Date()); setStatus("draft"); setNotes(""); setLineItems([emptyLine()]); } }, [open, mode, invoice]); // Load patients lazily for the create combobox. useEffect(() => { if (!open || mode !== "create") return; let active = true; listPatients() .then((data) => active && setPatients(data)) .catch(() => { /* search stays empty */ }); return () => { active = false; }; }, [open, mode]); const patientOptions = useMemo( () => patients.map((p) => ({ value: p.fileNumber, label: `${p.name} ${p.fileNumber}`, node: ( {p.name} #{p.fileNumber} ), })), [patients], ); const total = useMemo( () => lineItems.reduce((sum, li) => sum + li.quantity * li.unitPrice, 0), [lineItems], ); const updateLine = (index: number, patch: Partial) => setLineItems((prev) => prev.map((li, i) => (i === index ? { ...li, ...patch } : li)), ); const submit = async (event: FormEvent) => { event.preventDefault(); const patient = fixedPatient ?? selected; if (!patient) { notify.error( t("invoices.dialog.pickPatientTitle"), t("invoices.dialog.pickPatientBody"), ); return; } const cleanLines = lineItems.filter((li) => li.description.trim()); setBusy(true); try { const payload = { fileNumber: patient.fileNumber, name: patient.name, initials: patient.initials, issuedAt: keyOf(issuedAt), dueAt: hasDue ? keyOf(dueAt) : null, status, lineItems: cleanLines, notes: notes.trim() || null, }; const saved = mode === "edit" && invoice ? await updateInvoice(invoice.id, { ...payload, // Preserve fields the form doesn't edit. number: invoice.number, installments: invoice.installments, }) : await createInvoice(payload); onSaved(saved); if (sync.linked) { setWalletSummary( mode === "edit" ? t("walletSync.summary.invoiceUpdated", { number: saved.number }) : t("walletSync.summary.invoiceCreated", { number: saved.number }), ); setStep("wallet"); } else { onOpenChange(false); } } catch { notify.error(t("invoices.addFailedTitle"), t("invoices.addFailedBody")); } finally { setBusy(false); } }; return ( {mode === "edit" ? t("invoices.dialog.editTitle") : t("invoices.dialog.createTitle")} {t("invoices.dialog.description")} {sync.linked && } {step === "wallet" ? ( handleOpenChange(false)} patientName={activePatient?.name ?? ""} summary={walletSummary} sync={sync} /> ) : (
{fixedPatient ? (
{fixedPatient.name} {" "} ·{" "} {t("invoices.dialog.fileNumber", { number: fixedPatient.fileNumber || "—", })}
) : selected ? (
{selected.name} {t("invoices.dialog.fileNumber", { number: selected.fileNumber, })}
) : ( { const p = patients.find((x) => x.fileNumber === fileNumber); if (p) setSelected(p); }} options={patientOptions} placeholder={t("invoices.dialog.searchPlaceholder")} /> )}
{t("invoices.dialog.issued")}
{t("invoices.dialog.due")} setHasDue(e.target.checked)} type="checkbox" /> {hasDue ? ( ) : (
)}
{t("invoices.dialog.lineItems")}
{lineItems.map((li, i) => (
updateLine(i, { description: e.target.value }) } placeholder={t("invoices.dialog.lineDescriptionPlaceholder")} value={li.description} /> updateLine(i, { quantity: Number(e.target.value) || 0 }) } type="number" value={li.quantity} /> updateLine(i, { unitPrice: Number(e.target.value) || 0 }) } step="0.01" type="number" value={li.unitPrice} />
))}
{t("invoices.dialog.total")} {formatMoney(total)}