Fazekit

Code

"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;

More in Business

View all →