Commerce
Invoice detail
An invoice creation screen: customer, dates, payment terms, and a line-item table with a horizontal-scroll safeguard on narrow widths all feed a real-time summary. Preview opens the same data — recipient, line items, and totals — inside a Drawer instead of a permanent second column.
How it's built
The items table wraps in `overflow-x-auto` with a `min-w-[38rem]` inner flex column — QTY/Cost/Amount keep a comfortable natural width and scroll horizontally on narrow containers instead of a number or its unit suffix getting crushed out of view.
Every total (`Tax`, `Total`) is derived with `useMemo`/plain arithmetic from `items` and `discount` — there's exactly one source of truth, so the form and the Drawer preview can never drift apart.
Preview is a `Drawer`, not a static second column — it's a deliberate, on-demand action, and the form gets the full width to work in.
Source
One file · copy & pasteimport { useMemo, useState } from "react";
import { Button } from "oks-ui/button";
import { Divider } from "oks-ui/divider";
import { Drawer } from "oks-ui/drawer";
import { FormFieldSet } from "oks-ui/form-field-set";
import type { NavItemData } from "oks-ui/nav";
import { PageTitle } from "oks-ui/page-title";
import { Table } from "oks-ui/table";
import { PatternAppShell } from "../PatternAppShell";
import {
ClockIcon,
FileTextIcon,
GridIcon,
MessageCircleIcon,
PlusIcon,
ReceiptIcon,
SendIcon,
SettingsIcon,
TrashIcon,
UsersIcon,
} from "../../showcase/icons";
function BookmarkIcon({ size = 15 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
<path d="M3.5 2.5h9v11l-4.5-3-4.5 3Z" />
</svg>
);
}
function ArrowLeftIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13 8H3M7 4 3 8l4 4" />
</svg>
);
}
// An open eye — "Preview", not a reproduction of any icon set.
function EyeIcon({ size = 15 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1 8s2.5-4.5 7-4.5S15 8 15 8s-2.5 4.5-7 4.5S1 8 1 8Z" />
<circle cx="8" cy="8" r="2" />
</svg>
);
}
// A simple abstract mark (two overlapping waves), not a reproduction of
// any real brand's logo — the invoice's own issuer logo (business content),
// not the app's own identity, which is why it's separate from the shared
// PatternAppShell (that shell always shows the real oks-ui logo + account).
function LogoMark() {
return (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--r-md)] bg-ink text-paper">
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
<path d="M3 9c2-2 4-2 6 0s4 2 6 0 4-2 6 0" />
<path d="M3 15c2-2 4-2 6 0s4 2 6 0 4-2 6 0" />
</svg>
</span>
);
}
const NAV_ITEMS: NavItemData[] = [
{
key: "main",
label: "Main menu",
isSection: true,
children: [
{ key: "dashboard", label: "Dashboard", icon: <GridIcon size={18} /> },
{ key: "customers", label: "Customers", icon: <UsersIcon size={18} /> },
{ key: "services", label: "Services", icon: <SettingsIcon size={18} /> },
{ key: "invoices", label: "Invoices", icon: <ReceiptIcon size={18} /> },
{ key: "reports", label: "Reports", icon: <FileTextIcon size={18} /> },
],
},
{
key: "other",
label: "Other",
isSection: true,
children: [
{ key: "recurring", label: "Recurring", icon: <ClockIcon size={18} /> },
{ key: "feedback", label: "Feedback", icon: <MessageCircleIcon size={18} /> },
],
},
];
type LineItem = { id: number; item: string; qty: number; unit: string; cost: number };
const currency = (n: number) =>
`IDR ${n.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
// The datepicker fields store a plain "YYYY-MM-DD" — the invoice preview
// wants a full, human-readable date, not the field's own compact display.
const formatDate = (iso: string) => {
const [year, month, day] = iso.split("-").map(Number);
if (!year || !month || !day) return iso;
return new Date(year, month - 1, day).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
});
};
export function InvoiceDetail() {
const [previewOpen, setPreviewOpen] = useState(false);
const [customer, setCustomer] = useState("PT Nusantara Digital Solusi");
const [billingAddress, setBillingAddress] = useState("Jl. Jendral Sudirman No. 45 Jakarta Selatan, DKI Jakarta 12190 Indonesia");
const [issueDate, setIssueDate] = useState("2026-01-29");
const [dueDate, setDueDate] = useState("2026-02-12");
const [paymentTerms, setPaymentTerms] = useState("net14");
const [discount, setDiscount] = useState(500000);
const [notes, setNotes] = useState("Thank you for your trust. Please complete the payment before the due date.\nFor any questions, feel free to contact us.");
const [items, setItems] = useState<LineItem[]>([
{ id: 1, item: "Dashboard UI Design", qty: 10, unit: "page", cost: 750000 },
{ id: 2, item: "Mobile App", qty: 100, unit: "page", cost: 50000 },
]);
const subtotal = useMemo(() => items.reduce((sum, i) => sum + i.qty * i.cost, 0), [items]);
const tax = Math.round((subtotal - discount) * 0.11);
const total = subtotal - discount + tax;
const updateItem = (id: number, patch: Partial<LineItem>) => {
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, ...patch } : i)));
};
return (
<>
<PatternAppShell navItems={NAV_ITEMS} selectedNavKey="invoices">
<div className="flex flex-col gap-2">
<Button variant="link" size="sm" startContent={<ArrowLeftIcon />} className="self-start px-0 text-ink-muted">
Back to home
</Button>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle as="h2" title="Create New Invoice" classNames={{ base: "flex-col items-start", title: "text-2xl font-bold tracking-tight text-ink" }} />
<div className="flex items-center gap-2">
<Button variant="bordered" radius="full" size="sm" startContent={<BookmarkIcon />}>
Save as Draft
</Button>
<Button variant="bordered" radius="full" size="sm" startContent={<EyeIcon />} onClick={() => setPreviewOpen(true)}>
Preview
</Button>
<Button className="bg-ink text-paper hover:opacity-90" radius="full" size="sm" startContent={<SendIcon size={15} />}>
Send Invoice
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-5 rounded-[var(--r-lg)] bg-paper-raised p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-[0.95rem] font-semibold text-ink">Invoice Details</h3>
<Divider />
<FormFieldSet
type="text"
name="customer"
label="Customer"
value={customer}
onChange={(v: unknown) => setCustomer(String(v ?? ""))}
validation={{ rules: { required: true } }}
/>
<FormFieldSet
type="text"
name="billingAddress"
label="Billing Address"
value={billingAddress}
onChange={(v: unknown) => setBillingAddress(String(v ?? ""))}
validation={{ rules: { required: true } }}
/>
<div className="grid gap-4 @xl:grid-cols-3">
<FormFieldSet
type="datepicker"
name="issueDate"
label="Issue Date"
value={issueDate}
onChange={(v) => setIssueDate(String(v ?? ""))}
displayFormat="pretty"
/>
<FormFieldSet
type="datepicker"
name="dueDate"
label="Due Date"
value={dueDate}
onChange={(v) => setDueDate(String(v ?? ""))}
displayFormat="pretty"
minDate={issueDate}
/>
<FormFieldSet
type="select"
name="paymentTerms"
label="Payment Terms"
value={paymentTerms}
onChange={(v: unknown) => setPaymentTerms(String(v ?? ""))}
options={[
{ label: "Net 7", value: "net7" },
{ label: "Net 14", value: "net14" },
{ label: "Net 30", value: "net30" },
]}
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-[0.85rem] font-medium text-ink">Items Details</p>
{/* The row grid gets a real minimum width and its own
horizontal scroll instead of letting five columns
(name/qty/cost/amount/delete) get crushed below the point
where a prefix+number+suffix can actually fit inside one
field — the same "scroll, don't crush" fix already used on
the analytics-dashboard and data-table-view tables. */}
<div className="-mx-1 overflow-x-auto px-1">
<div className="flex min-w-[38rem] flex-col gap-2">
<div className="grid grid-cols-[1fr_7rem_9rem_8rem_2.5rem] items-center gap-2 text-[0.78rem] font-medium uppercase tracking-wide text-ink-faint">
<span>Item</span>
<span>QTY</span>
<span>Cost</span>
<span>Amount</span>
<span />
</div>
{items.map((row) => (
<div key={row.id} className="grid grid-cols-[1fr_7rem_9rem_8rem_2.5rem] items-center gap-2">
<FormFieldSet
type="text"
name={`item-${row.id}`}
label="Item"
classNames={{ label: "sr-only" }}
value={row.item}
onChange={(v: unknown) => updateItem(row.id, { item: String(v ?? "") })}
/>
<FormFieldSet
type="number"
name={`qty-${row.id}`}
label="Quantity"
classNames={{ label: "sr-only" }}
value={row.qty}
onChange={(v: unknown) => updateItem(row.id, { qty: Number(v) || 0 })}
suffix={row.unit}
/>
<FormFieldSet
type="number"
name={`cost-${row.id}`}
label="Cost"
classNames={{ label: "sr-only" }}
value={row.cost}
onChange={(v: unknown) => updateItem(row.id, { cost: Number(v) || 0 })}
prefix="IDR"
/>
<p className="text-right text-[0.85rem] text-ink-muted">{currency(row.qty * row.cost)}</p>
<Button
isIconOnly
aria-label={`Remove ${row.item || "item"}`}
variant="ghost"
size="sm"
onClick={() => setItems((prev) => prev.filter((i) => i.id !== row.id))}
>
<TrashIcon size={15} />
</Button>
</div>
))}
</div>
</div>
<Button
variant="link"
size="sm"
startContent={<PlusIcon size={14} />}
className="self-start px-0"
onClick={() =>
setItems((prev) => [...prev, { id: Date.now(), item: "", qty: 1, unit: "page", cost: 0 }])
}
>
Add Item
</Button>
</div>
<div className="grid gap-4 @xl:grid-cols-3">
<FormFieldSet
type="number"
name="discount"
label="Discount"
prefix="IDR"
value={discount}
onChange={(v: unknown) => setDiscount(Number(v) || 0)}
/>
<FormFieldSet type="text" name="taxDisplay" label="Tax (PPN 11%)" value={currency(tax)} disabled />
<FormFieldSet type="text" name="totalDisplay" label="Total" value={currency(total)} disabled />
</div>
<FormFieldSet
type="textarea"
name="notes"
label="Notes to Customer"
rows={3}
value={notes}
onChange={(v: unknown) => setNotes(String(v ?? ""))}
/>
</div>
</PatternAppShell>
{/* Live-reactive preview — moved off the main canvas and into a
Drawer so the editable form gets the full width and the preview
is a deliberate, on-demand action instead of a permanent second
column. */}
<Drawer
isOpen={previewOpen}
onClose={() => setPreviewOpen(false)}
position="right"
width="xl"
title="Preview"
>
<div className="flex flex-col gap-5 text-[0.85rem]">
<LogoMark />
<div className="grid grid-cols-3 gap-2">
<div>
<p className="text-ink-faint">Issue Date</p>
<p className="font-medium text-ink">{formatDate(issueDate)}</p>
</div>
<div>
<p className="text-ink-faint">Due Date</p>
<p className="font-medium text-ink">{formatDate(dueDate)}</p>
</div>
<div>
<p className="text-ink-faint">Payment Terms</p>
<p className="font-medium text-ink">{paymentTerms === "net7" ? "Net 7" : paymentTerms === "net30" ? "Net 30" : "Net 14"}</p>
</div>
</div>
<div>
<p className="text-ink-faint">Billed by:</p>
<p className="font-semibold text-ink">Studio Arsa Digital</p>
<p className="text-ink-muted">Jl. Jambu No.5, Semanding, Sumbersekar, Kec. Dau, Kabupaten Malang, Jawa Timur 65151</p>
</div>
<div>
<p className="text-ink-faint">Billed to:</p>
<p className="font-semibold text-ink">{customer || "—"}</p>
<p className="text-ink-muted">{billingAddress || "—"}</p>
</div>
<Table
aria-label="Invoice line items"
classNames={{ cell: "whitespace-nowrap" }}
columns={[
{ key: "item", header: "Item" },
{ key: "qty", header: "QTY", render: (r) => `${r.qty} ${r.unit}` },
{ key: "cost", header: "Cost", align: "end", render: (r) => currency(r.cost) },
{ key: "amount", header: "Amount", align: "end", render: (r) => currency(r.qty * r.cost) },
]}
rows={items}
getRowKey={(r) => r.id}
/>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-ink-faint">Bank Name</p>
<p className="font-medium text-ink">Bank Central Asia (BCA)</p>
<p className="mt-2 text-ink-faint">Account Name</p>
<p className="font-medium text-ink">Studio Arsa Digital</p>
<p className="mt-2 text-ink-faint">Account Number</p>
<p className="font-medium text-ink">123 456 7890</p>
</div>
<div className="flex flex-col gap-1 rounded-[var(--r-md)] bg-paper-sunken p-3">
<div className="flex justify-between gap-6 text-ink-soft">
<span>Subtotal</span>
<span>{currency(subtotal)}</span>
</div>
<div className="flex justify-between gap-6 text-ink-soft">
<span>Discount</span>
<span>{currency(discount)}</span>
</div>
<div className="flex justify-between gap-6 text-ink-soft">
<span>Tax (11%)</span>
<span>{currency(tax)}</span>
</div>
<Divider />
<div className="flex justify-between gap-6 font-semibold text-ink">
<span>Total</span>
<span>{currency(total)}</span>
</div>
</div>
</div>
<div>
<p className="text-ink-faint">Notes</p>
<p className="whitespace-pre-line text-ink-muted">{notes}</p>
</div>
</div>
</Drawer>
</>
);
}