frontend: invoice payment flow — pay, per-installment pay, due timeframe

- "Mark paid" button (settles invoice + all installments) when not paid/void
- per-installment Pay button + Paid badge; auto-marks invoice paid when the
  last installment clears
- overdue badge on past-due unpaid installments; due-through timeframe hint
- installments + split control hidden once the invoice is paid
All via the existing PUT /api/invoices/:id (no backend change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-17 19:07:44 +03:00
parent 29347488fa
commit 9503716eb3
3 changed files with 213 additions and 53 deletions
+10 -1
View File
@@ -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": {
+49
View File
@@ -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<Invoice> {
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<Invoice> {
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<Invoice> {
return apiFetch<Invoice>(`/api/invoices/${id}/split`, {
method: "POST",