"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { Receipt } from "lucide-react";
import { cn } from "@/lib/utils";
import { CLIENTS, ISSUER, SEED_INVOICES, SEED_TODAY } from "./data";
import { InvoiceEditor } from "./invoice-editor";
import { InvoiceList } from "./invoice-list";
import { InvoicePaper, PRINT_CSS } from "./invoice-preview";
import { addDays, nextNumber, totals } from "./invoice-utils";
import { Toasts, useToasts } from "./invoice-ui";
import { newLine } from "./line-items";
import type { Client, Invoice, Issuer } from "./types";
export type { Client, Invoice, Issuer, LineItem, InvoiceStatus } from "./types";
export interface InvoicingAppProps {
initialInvoices?: Invoice[];
clients?: Client[];
/** Your business details, printed on every invoice. */
issuer?: Issuer;
/** Currency used for the summary cards and new invoices. */
baseCurrency?: string;
currencies?: string[];
/** "Today" as YYYY-MM-DD (drives overdue status). Defaults to the seed date. */
today?: string;
/** Called with every invoice after any change. */
onChange?: (invoices: Invoice[]) => void;
/** Called when an invoice is sent or marked as paid. */
onStatusChange?: (invoice: Invoice) => void;
className?: string;
}
let seq = 0;
const newId = () => `inv${++seq}${Math.floor(performance.now()).toString(36)}`;
export function InvoicingApp({
initialInvoices,
clients = CLIENTS,
issuer = ISSUER,
baseCurrency = "USD",
currencies = ["USD", "EUR", "GBP", "CAD"],
today = SEED_TODAY,
onChange,
onStatusChange,
className,
}: InvoicingAppProps) {
const [invoices, setInvoices] = React.useState<Invoice[]>(initialInvoices ?? SEED_INVOICES);
const [openId, setOpenId] = React.useState<string | null>(null);
const { toasts, push, dismiss } = useToasts();
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(invoices);
}, [invoices]);
const open = invoices.find((i) => i.id === openId) ?? null;
const patch = (inv: Invoice) => setInvoices((list) => list.map((i) => (i.id === inv.id ? inv : i)));
const create = () => {
const inv: Invoice = {
id: newId(),
number: nextNumber(invoices),
clientId: null,
status: "draft",
issueDate: today,
dueDate: addDays(today, 14),
items: [newLine()],
taxRate: 8,
discount: { type: "percent", value: 0 },
notes: "Thank you! Payment is due within 14 days by bank transfer.",
currency: baseCurrency,
};
setInvoices((l) => [inv, ...l]);
setOpenId(inv.id);
};
const duplicate = (src: Invoice) => {
const inv: Invoice = { ...src, id: newId(), number: nextNumber(invoices), status: "draft", issueDate: today, dueDate: addDays(today, Math.max(7, (Date.parse(src.dueDate) - Date.parse(src.issueDate)) / 86_400_000)), paidAt: undefined, items: src.items.map((it) => ({ ...it, id: newLine().id })) };
setInvoices((l) => [inv, ...l]);
setOpenId(inv.id);
push(`Duplicated as ${inv.number}`);
};
const setStatus = (inv: Invoice, status: Invoice["status"]) => {
const next: Invoice = { ...inv, status, paidAt: status === "paid" ? today : undefined };
patch(next);
onStatusChange?.(next);
push(status === "paid" ? `${inv.number} marked as paid · ${new Intl.NumberFormat("en-US", { style: "currency", currency: inv.currency }).format(totals(inv).total)}` : `${inv.number} sent`, () => patch(inv));
};
const remove = (inv: Invoice) => {
const snapshot = invoices;
setInvoices((l) => l.filter((i) => i.id !== inv.id));
setOpenId(null);
push(`Deleted ${inv.number}`, () => setInvoices(snapshot));
};
const print = () => {
// Blur so no focus ring lands on the printout, then let layout settle.
(document.activeElement as HTMLElement | null)?.blur();
requestAnimationFrame(() => window.print());
};
const printClient = open ? clients.find((c) => c.id === open.clientId) : undefined;
return (
<MotionConfig reducedMotion="user">
<style>{PRINT_CSS}</style>
<div className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
{/* Rail (desktop, list view) */}
<aside className={cn("hidden w-56 shrink-0 flex-col border-r bg-muted/40 lg:flex dark:bg-muted/20", open && "lg:hidden")}>
<div className="flex h-14 items-center gap-2.5 border-b px-4">
<span className="grid size-7 place-items-center rounded-lg bg-gradient-to-br from-primary to-fuchsia-500 text-white shadow-sm shadow-primary/30">
<Receipt className="size-3.5" aria-hidden />
</span>
<span className="truncate text-[13px] font-semibold">{issuer.name}</span>
</div>
<div className="space-y-5 p-4 text-xs">
<Aging invoices={invoices} today={today} currency={baseCurrency} />
<div>
<div className="font-medium text-muted-foreground">Clients</div>
<ul className="mt-2 space-y-2">
{clients.slice(0, 6).map((c) => {
const owed = invoices.filter((i) => i.clientId === c.id && i.status === "sent").length;
return (
<li key={c.id} className="flex items-center justify-between gap-2">
<span className="truncate">{c.name}</span>
{owed > 0 && <span className="rounded-full bg-sky-500/10 px-1.5 text-[10px] font-semibold text-sky-700 dark:text-sky-300">{owed} open</span>}
</li>
);
})}
</ul>
</div>
</div>
<p className="mt-auto border-t p-4 text-[11px] leading-relaxed text-muted-foreground">
Tip: press <kbd className="rounded border px-1 font-mono text-[10px]">N</kbd> for a new invoice.
</p>
</aside>
<main className="relative min-w-0 flex-1">
<AnimatePresence mode="popLayout" initial={false}>
{open ? (
<motion.div key="editor" className="absolute inset-0" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<InvoiceEditor
invoice={open}
clients={clients}
issuer={issuer}
today={today}
currencies={currencies}
onChange={patch}
onBack={() => setOpenId(null)}
onMarkPaid={() => setStatus(open, "paid")}
onSend={() => setStatus(open, "sent")}
onDuplicate={() => duplicate(open)}
onDelete={() => remove(open)}
onPrint={print}
/>
</motion.div>
) : (
<motion.div key="list" className="absolute inset-0 flex flex-col" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 sm:px-5">
<span className="grid size-7 place-items-center rounded-lg bg-gradient-to-br from-primary to-fuchsia-500 text-white lg:hidden">
<Receipt className="size-3.5" aria-hidden />
</span>
<h2 className="text-sm font-semibold">Invoices</h2>
<span className="text-xs text-muted-foreground">· {invoices.length} total</span>
</header>
<div className="min-h-0 flex-1">
<InvoiceList invoices={invoices} clients={clients} today={today} baseCurrency={baseCurrency} activeId={openId} onOpen={setOpenId} onNew={create} />
</div>
</motion.div>
)}
</AnimatePresence>
</main>
<KeyShortcuts enabled={!open} onNew={create} onEscape={() => setOpenId(null)} />
<Toasts toasts={toasts} dismiss={dismiss} />
{open && (
<div data-invoice-print aria-hidden className="hidden">
<InvoicePaper invoice={open} client={printClient} issuer={issuer} today={today} />
</div>
)}
</div>
</MotionConfig>
);
}
function Aging({ invoices, today, currency }: { invoices: Invoice[]; today: string; currency: string }) {
const buckets = [
{ label: "Not yet due", min: -Infinity, max: 0, cls: "bg-sky-500" },
{ label: "1–30 days", min: 1, max: 30, cls: "bg-amber-500" },
{ label: "31–60 days", min: 31, max: 60, cls: "bg-orange-500" },
{ label: "60+ days", min: 61, max: Infinity, cls: "bg-rose-500" },
].map((b) => {
const xs = invoices.filter((i) => {
if (i.status !== "sent" || i.currency !== currency) return false;
const late = Math.round((Date.parse(`${today}T12:00:00Z`) - Date.parse(`${i.dueDate}T12:00:00Z`)) / 86_400_000);
return late >= b.min && late <= b.max;
});
return { ...b, value: xs.reduce((s, i) => s + totals(i).total, 0) };
});
const max = Math.max(1, ...buckets.map((b) => b.value));
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency, notation: "compact", maximumFractionDigits: 1 });
return (
<section aria-labelledby="aging-h">
<h3 id="aging-h" className="font-medium text-muted-foreground">
Receivables aging
</h3>
<ul className="mt-2.5 space-y-2.5">
{buckets.map((b, i) => (
<li key={b.label}>
<div className="flex justify-between text-[11px]">
<span>{b.label}</span>
<span className="font-medium tabular-nums">{fmt.format(b.value)}</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-border/70">
<motion.div className={cn("h-full rounded-full", b.cls)} initial={{ width: 0 }} animate={{ width: `${(b.value / max) * 100}%` }} transition={{ delay: 0.1 + i * 0.06, type: "spring", stiffness: 120, damping: 20 }} />
</div>
</li>
))}
</ul>
</section>
);
}
function KeyShortcuts({ enabled, onNew, onEscape }: { enabled: boolean; onNew: () => void; onEscape: () => void }) {
const ref = React.useRef({ enabled, onNew, onEscape });
React.useLayoutEffect(() => {
ref.current = { enabled, onNew, onEscape };
});
React.useEffect(() => {
const h = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.metaKey || e.ctrlKey || e.altKey || t.closest("input,textarea,select,[contenteditable]")) return;
if (e.key === "Escape" && !ref.current.enabled) {
if (document.querySelector('[aria-expanded="true"][aria-haspopup="listbox"]')) return;
ref.current.onEscape();
} else if ((e.key === "n" || e.key === "N") && ref.current.enabled) {
e.preventDefault();
ref.current.onNew();
}
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, []);
return null;
}
export default InvoicingApp;