diff --git a/frontend/components/invoices/invoice-detail-sheet.tsx b/frontend/components/invoices/invoice-detail-sheet.tsx index c7a063e..484e5d2 100644 --- a/frontend/components/invoices/invoice-detail-sheet.tsx +++ b/frontend/components/invoices/invoice-detail-sheet.tsx @@ -1,6 +1,12 @@ "use client"; -import { Download, Pencil, Split, Trash2 } from "lucide-react"; +import { + CircleCheck, + Download, + Pencil, + Split, + Trash2, +} from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -24,6 +30,9 @@ import { type Invoice, type InvoiceStatus, invoiceTotal, + isInstallmentOverdue, + markInvoicePaid, + payInstallment, splitInvoice, } from "@/lib/invoices"; import { notify } from "@/lib/toast"; @@ -74,6 +83,14 @@ export function InvoiceDetailSheet({ } const total = invoiceTotal(invoice); + const isPaid = invoice.status === "paid"; + const isSettled = isPaid || invoice.status === "void"; + // The schedule window: the latest installment due date, for the timeframe hint. + const lastDue = invoice.installments.reduce( + (latest, it) => + it.dueAt && (!latest || it.dueAt > latest) ? it.dueAt : latest, + null, + ); const split = async () => { setBusy(true); @@ -94,6 +111,41 @@ export function InvoiceDetailSheet({ } }; + const payAll = async () => { + setBusy(true); + try { + const updated = await markInvoicePaid(invoice); + onChanged(updated); + notify.success(t("invoices.sheet.paidTitle"), invoice.number); + } catch { + notify.error( + t("invoices.sheet.payFailedTitle"), + t("invoices.sheet.payFailedBody"), + ); + } finally { + setBusy(false); + } + }; + + const payOne = async (index: number) => { + setBusy(true); + try { + const updated = await payInstallment(invoice, index); + onChanged(updated); + notify.success( + t("invoices.sheet.installmentPaidTitle"), + invoice.installments[index]?.label ?? invoice.number, + ); + } catch { + notify.error( + t("invoices.sheet.payFailedTitle"), + t("invoices.sheet.payFailedBody"), + ); + } finally { + setBusy(false); + } + }; + const remove = async () => { setBusy(true); try { @@ -185,58 +237,98 @@ export function InvoiceDetailSheet({ -
- - {t("invoices.sheet.installments")} - - {invoice.installments.length > 0 ? ( -
- {invoice.installments.map((it, i) => ( -
- {it.label} - - {it.dueAt ? formatInvoiceDate(it.dueAt) : "—"} - - - {formatMoney(it.amount)} - -
- ))} + {/* Installments are a payment plan for an open invoice — once it's + settled there's nothing left to schedule, so hide them. */} + {isPaid ? null : ( +
+ + {t("invoices.sheet.installments")} + + {invoice.installments.length > 0 ? ( +
+ {invoice.installments.map((it, i) => { + const overdue = isInstallmentOverdue(it); + return ( +
+ + + {it.label} + + {overdue ? ( + + {t("invoices.sheet.overdue")} + + ) : null} + + + {it.dueAt ? formatInvoiceDate(it.dueAt) : "—"} + + + {formatMoney(it.amount)} + + {it.paid ? ( + + {t("invoices.sheet.installmentPaid")} + + ) : ( + + )} +
+ ); + })} +
+ ) : ( +

+ {t("invoices.sheet.noInstallments")} +

+ )} + {invoice.installments.length > 0 && lastDue ? ( +

+ {t("invoices.sheet.timeframe", { + count: invoice.installments.length, + date: formatInvoiceDate(lastDue), + })} +

+ ) : null} +
+ +
- ) : ( -

- {t("invoices.sheet.noInstallments")} -

- )} -
- -
-
+ )} {invoice.notes ? (

@@ -265,7 +357,17 @@ export function InvoiceDetailSheet({ {t("invoices.sheet.download")} - + )} + diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index eb3d20c..0902219 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -361,7 +361,16 @@ "deleteFailedTitle": "Couldn't delete invoice", "deleteFailedBody": "Please try again.", "paid": "Paid", - "unpaid": "Unpaid" + "unpaid": "Unpaid", + "markPaid": "Mark paid", + "paidTitle": "Invoice marked paid", + "payFailedTitle": "Couldn't record payment", + "payFailedBody": "Please try again.", + "payInstallment": "Pay", + "installmentPaid": "Paid", + "installmentPaidTitle": "Installment paid", + "overdue": "Overdue", + "timeframe": "{{count}} payments · due through {{date}}" } }, "prescriptions": { diff --git a/frontend/lib/invoices.ts b/frontend/lib/invoices.ts index f45ede8..c0437c9 100644 --- a/frontend/lib/invoices.ts +++ b/frontend/lib/invoices.ts @@ -98,6 +98,55 @@ export function updateInvoice( }); } +// Rebuild the editable input payload from a full invoice, so a partial change +// (paying it, paying an installment) round-trips through PUT without dropping +// any fields. +function invoiceToInput(inv: Invoice): InvoiceInput { + return { + fileNumber: inv.fileNumber, + name: inv.name, + initials: inv.initials, + number: inv.number, + issuedAt: inv.issuedAt, + dueAt: inv.dueAt, + status: inv.status, + lineItems: inv.lineItems, + installments: inv.installments, + notes: inv.notes, + source: inv.source, + }; +} + +// Mark the whole invoice paid: status → paid and every installment settled. +export function markInvoicePaid(inv: Invoice): Promise { + return updateInvoice(inv.id, { + ...invoiceToInput(inv), + status: "paid", + installments: inv.installments.map((it) => ({ ...it, paid: true })), + }); +} + +// Settle a single installment. When that clears the last one, the invoice flips +// to paid automatically. +export function payInstallment(inv: Invoice, index: number): Promise { + const installments = inv.installments.map((it, i) => + i === index ? { ...it, paid: true } : it, + ); + const allPaid = installments.length > 0 && installments.every((it) => it.paid); + return updateInvoice(inv.id, { + ...invoiceToInput(inv), + installments, + status: allPaid ? "paid" : inv.status, + }); +} + +// True when an unpaid installment's due date has passed. +export function isInstallmentOverdue(it: InvoiceInstallment): boolean { + if (it.paid || !it.dueAt) return false; + const due = new Date(`${it.dueAt}T23:59:59`); + return !Number.isNaN(due.getTime()) && due.getTime() < Date.now(); +} + export function splitInvoice(id: string, count: number): Promise { return apiFetch(`/api/invoices/${id}/split`, { method: "POST",