Skip to content

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.

invoice-detail.tsx

Built with

How it’s built

  1. 01The 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.
  2. 02Every 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.
  3. 03Preview 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

Three files · copy & paste

Styling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.

invoice-detail.tsx
import { 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 { AppShell } from "./AppShell";
import {
  ClockIcon,
  FileTextIcon,
  GridIcon,
  MessageCircleIcon,
  PlusIcon,
  ReceiptIcon,
  SendIcon,
  SettingsIcon,
  TrashIcon,
  UsersIcon,
} from "./icons";

const 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>
  );
};

const 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.
const 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
// AppShell (that shell always shows the real oks-ui logo + account).
const 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",
  });
};

const 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 (
    <>
      <AppShell 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>
      </AppShell>

      {/* 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>
    </>
  );
};

export default InvoiceDetail;

It imports ./icons, so save this next to it:

icons.tsx
export const ClockIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M12 7v5l3 3" />
    </svg>
  );
};

export const FileTextIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5z" />
      <path d="M14 2v6h6" />
      <path d="M9 13h6M9 17h6M9 9h2" />
    </svg>
  );
};

export const GridIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="3" width="7" height="7" rx="1.5" />
      <rect x="14" y="3" width="7" height="7" rx="1.5" />
      <rect x="3" y="14" width="7" height="7" rx="1.5" />
      <rect x="14" y="14" width="7" height="7" rx="1.5" />
    </svg>
  );
};

export const MessageCircleIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
    </svg>
  );
};

export const PlusIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 5v14M5 12h14" />
    </svg>
  );
};

export const ReceiptIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M5 3v18l2-1.5L9 21l2-1.5L13 21l2-1.5L17 21l2-1.5V3l-2 1.5L15 3l-2 1.5L11 3 9 4.5 7 3 5 4.5Z" />
      <path d="M8 8h8M8 12h8" />
    </svg>
  );
};

export const SendIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m22 2-7 20-4-9-9-4Z" />
      <path d="M22 2 11 13" />
    </svg>
  );
};

// Icons below this line were added for Slate's rebuild into a personal
// finance & invoicing app -- same "no icon library dependency" convention
// above.

export const SettingsIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="3" />
      <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.42.7.7 1.19.83l.32.08c.6.15 1 .68 1 1.3v0" />
    </svg>
  );
};

export const TrashIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3 6h18" />
      <path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
      <path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
    </svg>
  );
};

export const UsersIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
      <circle cx="9" cy="7" r="4" />
      <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
      <path d="M16 3.13a4 4 0 0 1 0 7.75" />
    </svg>
  );
};

export const BellIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6Z" />
      <path d="M10 20a2 2 0 0 0 4 0" />
    </svg>
  );
};

export const HelpCircleIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M9.5 9a2.5 2.5 0 0 1 4.8 1c0 1.5-2.3 1.8-2.3 3.5" />
      <path d="M12 17.5h.01" />
    </svg>
  );
};

// Icons below this line were added for Horizon's rebuild into a business
// back-office (Finance/Accounts/HR and Payroll/Peoples/Contracts) --
// same "no icon library dependency" convention as everything above.

export const SearchIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="11" cy="11" r="7" />
      <path d="M21 21l-4.3-4.3" />
    </svg>
  );
};

It imports ./AppShell, so save this next to it:

AppShell.tsx
"use client";

import { useState, type ReactNode } from "react";
import { Avatar } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { FormFieldSet } from "oks-ui/form-field-set";
import { Nav } from "oks-ui/nav";
import type { NavItemData } from "oks-ui/nav";
import "oks-ui/avatar.css";
import "oks-ui/button.css";
import "oks-ui/dropdown.css";
import "oks-ui/form-field-set.css";
import "oks-ui/nav.css";
import { BellIcon, HelpCircleIcon, SearchIcon, SettingsIcon } from "./icons";

// Two overlapping chevrons — a standard "collapse sidebar" glyph, not a
// reproduction of any specific icon set's artwork.
const CollapseIcon = () => {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
      <path d="M6 3 2 8l4 5" />
      <path d="M12 3 8 8l4 5" />
    </svg>
  );
};

const LogOutIcon = ({ size = 16 }: { size?: number }) => {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M6 14H3.5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1H6" />
      <path d="M10.5 11 14 8l-3.5-3" />
      <path d="M14 8H6" />
    </svg>
  );
};

export type AppShellProps = {
  /** The sidebar tree — differs per pattern to match its app context (a Kanban tool's nav reads differently from an invoicing tool's). */
  navItems: NavItemData[];
  /** Omitted when the pattern being shown isn't one of the permanent nav rows (e.g. a Settings page reached from the account menu, not the sidebar itself). */
  selectedNavKey?: string;
  /** Lets a pattern make its own Nav row genuinely clickable (analytics-dashboard highlights whichever item — or account-menu action — was clicked last) instead of a fixed selectedNavKey. */
  onSelectedNavKeyChange?: (key: string) => void;
  /** Left side of the header, after the collapse toggle. Defaults to a plain search field; command-palette-shell overrides this with its ⌘K trigger button instead. */
  headerLeft?: ReactNode;
  /** Right side of the header, after the standard bell. Defaults to nothing extra. */
  headerExtra?: ReactNode;
  /** The scrollable main content (everything below the header). */
  children: ReactNode;
  /** Extra classes for the scrollable content wrapper — e.g. to drop the default padding for a pattern that wants edge-to-edge content. */
  contentClassName?: string;
};

/**
 * The one persistent sidebar/header app shell every "App screens" pattern on
 * this site shares. Pulled out of each individual pattern file after the
 * per-file copies drifted (one ended up with a different account name than
 * the rest) — the account identity (Omkar Sahu) and the structural chrome
 * (collapse toggle, Nav wiring, account Dropdown, bell) now live in exactly
 * one place, while each pattern still supplies its own contextual nav items
 * and can override the header's search slot when it needs to (the command
 * palette trigger, for instance).
 */
export const AppShell = ({
  navItems,
  selectedNavKey,
  onSelectedNavKeyChange,
  headerLeft,
  headerExtra,
  children,
  contentClassName,
}: AppShellProps) => {
  const [collapsed, setCollapsed] = useState(false);

  return (
    // @container has to live on its OWN wrapper, not the element whose
    // height is conditioned by @3xl: — an element can't query its own
    // container for its own styling (circular per the CSS Containment
    // spec), so @3xl:h-[46rem] would silently be evaluated against the
    // wrong (ancestor) container and never apply.
    <div className="@container w-full">
      <div className="flex h-[32rem] w-full overflow-hidden bg-paper @3xl:h-[46rem]">
        <aside
          role="none"
          className={`hidden h-full shrink-0 flex-col gap-6 overflow-y-auto overflow-x-hidden border-r border-line bg-paper-raised p-4 @3xl:flex ${collapsed ? "w-[4.5rem]" : "w-60"}`}
        >
          <div className="flex items-center px-1">
            {/* Two files, swapped by theme — logo.svg carries dark ink and would
                vanish against the dark sidebar. eslint-disable: fixed static
                assets already sized for this slot; next/image's runtime cost
                isn't worth it here. */}
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img src="/logo.svg" alt="oks-ui" className="oks-logo-for-light h-8 w-8 shrink-0" />
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img src="/logo-light.svg" alt="oks-ui" className="oks-logo-for-dark h-8 w-8 shrink-0" />
          </div>

          <Nav
            aria-label="Main"
            items={navItems}
            selectedKey={selectedNavKey}
            onItemSelect={onSelectedNavKeyChange ? (item) => onSelectedNavKeyChange(item.key) : undefined}
            isCollapsed={collapsed}
            classNames={{ base: "w-full", sectionTitle: collapsed ? "sr-only" : undefined }}
            renderItem={({ isActive, content, props }) => (
              <button type="button" {...props} className={`${props.className} flex items-center gap-1.5`}>
                {!collapsed ? (
                  <span
                    aria-hidden="true"
                    className="h-1.5 w-1.5 shrink-0 rounded-full"
                    style={{ background: isActive ? "var(--oks-color-primary-600)" : "transparent" }}
                  />
                ) : null}
                {content}
              </button>
            )}
          />

          <Dropdown placement="top-start" className="mt-auto w-full" classNames={{ content: "block w-full" }}>
            <DropdownTrigger>
              <Button
                variant="ghost"
                fullWidth
                className={`gap-2.5 p-2 ${collapsed ? "justify-center" : "justify-start"}`}
              >
                <Avatar src="/avatar-omkar.jpg" name="Omkar Sahu" size="sm" radius="full" />
                {!collapsed ? (
                  <span className="min-w-0 flex-1 text-left">
                    <span className="block truncate text-[0.85rem] font-medium text-ink">Omkar Sahu</span>
                    <span className="block truncate text-[0.75rem] text-ink-muted">me@oks-ui.com</span>
                  </span>
                ) : null}
              </Button>
            </DropdownTrigger>
            <DropdownMenu
              aria-label="Account menu"
              onAction={onSelectedNavKeyChange ? (key) => onSelectedNavKeyChange(String(key)) : undefined}
            >
              <DropdownItem key="settings" startContent={<SettingsIcon size={16} />}>
                Settings
              </DropdownItem>
              <DropdownItem key="help" startContent={<HelpCircleIcon size={16} />} showDivider>
                Help desk
              </DropdownItem>
              <DropdownItem key="logout" startContent={<LogOutIcon size={16} />} classNames={{ title: "text-[color:var(--oks-color-danger-700)]" }}>
                Log out
              </DropdownItem>
            </DropdownMenu>
          </Dropdown>
        </aside>

        <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
          <header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-line px-5 py-3.5 @3xl:px-8">
            <div className="flex min-w-0 flex-1 items-center gap-3">
              <Button
                isIconOnly
                variant="ghost"
                size="sm"
                onClick={() => setCollapsed((c) => !c)}
                aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
                className="hidden @3xl:flex"
              >
                <CollapseIcon />
              </Button>
              {headerLeft ?? (
                <FormFieldSet
                  type="search"
                  name="headerSearch"
                  label="Search"
                  placeholder="Search"
                  startIcon={<SearchIcon size={16} />}
                  classNames={{ base: "w-full max-w-xs", label: "sr-only", control: "border-transparent bg-paper-sunken" }}
                />
              )}
            </div>
            <div className="flex items-center gap-3">
              {headerExtra}
              <Button isIconOnly aria-label="Notifications" variant="ghost" radius="full" size="sm" className="relative">
                <BellIcon size={18} />
                <span aria-hidden="true" className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full" style={{ background: "var(--oks-color-danger-500)" }} />
              </Button>
              <Avatar src="/avatar-omkar.jpg" name="Omkar Sahu" size="sm" radius="full" />
            </div>
          </header>

          <div className={contentClassName ?? "flex flex-1 flex-col gap-5 overflow-y-auto p-5 @3xl:p-8"}>{children}</div>
        </div>
      </div>
    </div>
  );
};