mirror of
https://github.com/temetro/temetro.git
synced 2026-08-30 20:39:06 +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";
|
"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 { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -24,6 +30,9 @@ import {
|
|||||||
type Invoice,
|
type Invoice,
|
||||||
type InvoiceStatus,
|
type InvoiceStatus,
|
||||||
invoiceTotal,
|
invoiceTotal,
|
||||||
|
isInstallmentOverdue,
|
||||||
|
markInvoicePaid,
|
||||||
|
payInstallment,
|
||||||
splitInvoice,
|
splitInvoice,
|
||||||
} from "@/lib/invoices";
|
} from "@/lib/invoices";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
@@ -74,6 +83,14 @@ export function InvoiceDetailSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = invoiceTotal(invoice);
|
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 () => {
|
const split = async () => {
|
||||||
setBusy(true);
|
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 () => {
|
const remove = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -185,58 +237,98 @@ export function InvoiceDetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{/* Installments are a payment plan for an open invoice — once it's
|
||||||
<span className="text-muted-foreground text-xs">
|
settled there's nothing left to schedule, so hide them. */}
|
||||||
{t("invoices.sheet.installments")}
|
{isPaid ? null : (
|
||||||
</span>
|
<div className="flex flex-col gap-2">
|
||||||
{invoice.installments.length > 0 ? (
|
<span className="text-muted-foreground text-xs">
|
||||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
|
{t("invoices.sheet.installments")}
|
||||||
{invoice.installments.map((it, i) => (
|
</span>
|
||||||
<div
|
{invoice.installments.length > 0 ? (
|
||||||
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: positional
|
{invoice.installments.map((it, i) => {
|
||||||
key={i}
|
const overdue = isInstallmentOverdue(it);
|
||||||
>
|
return (
|
||||||
<span className="text-foreground">{it.label}</span>
|
<div
|
||||||
<span className="text-muted-foreground text-xs">
|
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||||
{it.dueAt ? formatInvoiceDate(it.dueAt) : "—"}
|
// biome-ignore lint/suspicious/noArrayIndexKey: positional
|
||||||
</span>
|
key={i}
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
>
|
||||||
{formatMoney(it.amount)}
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
</span>
|
<span className="truncate text-foreground">
|
||||||
</div>
|
{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>
|
</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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{invoice.notes ? (
|
{invoice.notes ? (
|
||||||
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
|
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
|
||||||
@@ -265,7 +357,17 @@ export function InvoiceDetailSheet({
|
|||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
{t("invoices.sheet.download")}
|
{t("invoices.sheet.download")}
|
||||||
</Button>
|
</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" />
|
<Pencil className="size-4" />
|
||||||
{t("invoices.sheet.edit")}
|
{t("invoices.sheet.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -361,7 +361,16 @@
|
|||||||
"deleteFailedTitle": "Couldn't delete invoice",
|
"deleteFailedTitle": "Couldn't delete invoice",
|
||||||
"deleteFailedBody": "Please try again.",
|
"deleteFailedBody": "Please try again.",
|
||||||
"paid": "Paid",
|
"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": {
|
"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> {
|
export function splitInvoice(id: string, count: number): Promise<Invoice> {
|
||||||
return apiFetch<Invoice>(`/api/invoices/${id}/split`, {
|
return apiFetch<Invoice>(`/api/invoices/${id}/split`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user