mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user