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
@@ -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<string | null>(
(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({
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground text-xs">
{t("invoices.sheet.installments")}
</span>
{invoice.installments.length > 0 ? (
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
{invoice.installments.map((it, i) => (
<div
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
// biome-ignore lint/suspicious/noArrayIndexKey: positional
key={i}
>
<span className="text-foreground">{it.label}</span>
<span className="text-muted-foreground text-xs">
{it.dueAt ? formatInvoiceDate(it.dueAt) : "—"}
</span>
<span className="font-medium text-foreground tabular-nums">
{formatMoney(it.amount)}
</span>
</div>
))}
{/* Installments are a payment plan for an open invoice — once it's
settled there's nothing left to schedule, so hide them. */}
{isPaid ? null : (
<div className="flex flex-col gap-2">
<span className="text-muted-foreground text-xs">
{t("invoices.sheet.installments")}
</span>
{invoice.installments.length > 0 ? (
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
{invoice.installments.map((it, i) => {
const overdue = isInstallmentOverdue(it);
return (
<div
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
// biome-ignore lint/suspicious/noArrayIndexKey: positional
key={i}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-foreground">
{it.label}
</span>
{overdue ? (
<Badge variant="destructive">
{t("invoices.sheet.overdue")}
</Badge>
) : null}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{it.dueAt ? formatInvoiceDate(it.dueAt) : "—"}
</span>
<span className="shrink-0 font-medium text-foreground tabular-nums">
{formatMoney(it.amount)}
</span>
{it.paid ? (
<Badge className="shrink-0" variant="outline">
{t("invoices.sheet.installmentPaid")}
</Badge>
) : (
<Button
className="shrink-0"
disabled={busy}
onClick={() => payOne(i)}
size="sm"
type="button"
variant="outline"
>
{t("invoices.sheet.payInstallment")}
</Button>
)}
</div>
);
})}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("invoices.sheet.noInstallments")}
</p>
)}
{invoice.installments.length > 0 && lastDue ? (
<p className="text-muted-foreground text-xs">
{t("invoices.sheet.timeframe", {
count: invoice.installments.length,
date: formatInvoiceDate(lastDue),
})}
</p>
) : null}
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
{t("invoices.sheet.splitCount")}
</span>
<Input
className="w-20"
max={36}
min={1}
onChange={(e) => setCount(Number(e.target.value) || 1)}
type="number"
value={count}
/>
</label>
<Button
disabled={busy}
onClick={split}
type="button"
variant="outline"
>
<Split className="size-4" />
{t("invoices.sheet.split")}
</Button>
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("invoices.sheet.noInstallments")}
</p>
)}
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
{t("invoices.sheet.splitCount")}
</span>
<Input
className="w-20"
max={36}
min={1}
onChange={(e) => setCount(Number(e.target.value) || 1)}
type="number"
value={count}
/>
</label>
<Button
disabled={busy}
onClick={split}
type="button"
variant="outline"
>
<Split className="size-4" />
{t("invoices.sheet.split")}
</Button>
</div>
</div>
)}
{invoice.notes ? (
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
@@ -265,7 +357,17 @@ export function InvoiceDetailSheet({
<Download className="size-4" />
{t("invoices.sheet.download")}
</Button>
<Button onClick={() => onEdit(invoice)} type="button">
{isSettled ? null : (
<Button disabled={busy} onClick={payAll} type="button">
<CircleCheck className="size-4" />
{t("invoices.sheet.markPaid")}
</Button>
)}
<Button
onClick={() => onEdit(invoice)}
type="button"
variant={isSettled ? "default" : "outline"}
>
<Pencil className="size-4" />
{t("invoices.sheet.edit")}
</Button>
+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",