Zubeyr··PagesformscrmOrder Edit
A CRM record edit page: a clickable stage path, editable line items with a product picker, custom items, price and quantity edits and restore, live totals with balance due or refund, a schema-driven record form, a review of unsaved changes, undo/redo and a floating Cancel and Save toolbar. Pass your own sections, values, items and catalog to reuse it for deals, quotes or invoices.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/order-edit.json- →Installs
lucide-reactfrom npm. - →Pulls in
sidebar. - →Pulls in
button. - →Pulls in
badge. - →Pulls in
record-form. - →Pulls in
use-history. - →Pulls in
toast. - →Pulls in
popover. - →Files land in
components/dashboard/order-edit.
Installing by hand? Add the dependencies, then copy the source code below.
pnpm add lucide-reactUsage
import { OrderEdit } from '@/components/dashboard/order-edit/order-edit'
export default function Demo() {
return <OrderEdit />
}Source code
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
BarChart3,
ChevronDown,
ChevronUp,
Globe,
Home,
Package,
Redo2,
ShoppingBag,
Undo2,
Users,
X,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Badge } from '@/registry/dashboard/components/badge/badge'
import { Button } from '@/registry/dashboard/components/button/button'
import { ToggleField } from '@/registry/dashboard/components/record-form/controls'
import {
RecordForm,
recordFormResolver,
visibleFields,
type FormLayout,
type FormSection,
type FormValues,
ChangeList,
normalizeOptions,
} from '@/registry/dashboard/components/record-form/record-form'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarItem,
} from '@/registry/dashboard/components/sidebar/sidebar'
import { toast, Toaster } from '@/registry/dashboard/components/toast/toast'
import { useHistory } from '@/registry/dashboard/components/use-history/use-history'
import { diffRecord } from './changes'
import * as demo from './data'
import { LineItemEditor, type EditItem, type Product } from './line-item-editor'
import { StagePath } from './stage-path'
import { computeTotals, TotalsCard } from './totals'
type Snapshot = { values: FormValues; items: EditItem[] }
export type OrderEditProps = {
title?: string
subtitle?: string
sections?: FormSection[]
values?: FormValues
items?: EditItem[]
catalog?: Product[]
stageField?: string
fields?: { discount?: string; shipping?: string; code?: string }
badges?: { label: string; tone?: React.ComponentProps<typeof Badge>['tone'] }[]
formLayout?: FormLayout
paid?: number
taxRate?: number
formatOptions?: Intl.NumberFormatOptions
}
const num = (value: unknown) =>
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) || 0 : 0
export function OrderEdit({
title = 'Order #1078',
subtitle = 'Placed Oct 29, 2026 via Online Store',
sections = demo.sections,
values: initialValues = demo.values,
items: initialItems = demo.items,
catalog = demo.catalog,
stageField = 'stage',
fields = demo.totalFields,
badges = demo.badges,
formLayout = 'accordion',
paid = 394.49,
taxRate = 0.08875,
formatOptions = { style: 'currency', currency: 'USD' },
}: OrderEditProps) {
const initial = useMemo(
() => ({ values: initialValues, items: initialItems }),
[initialValues, initialItems],
)
const history = useHistory<Snapshot>(initial, { hotkeys: true })
const { values, items } = history.value
const [saved, setSaved] = useState<Snapshot>(initial)
const [itemsError, setItemsError] = useState('')
const [notify, setNotify] = useState(true)
const [justSaved, setJustSaved] = useState(false)
const money = useMemo(() => new Intl.NumberFormat('en-US', formatOptions), [formatOptions])
const changes = diffRecord(sections, saved, history.value)
const dirty = changes.length > 0
const stageDef = sections.flatMap((s) => s.fields).find((f) => f.name === stageField)
const stageOptions = stageDef?.options && normalizeOptions(stageDef.options).map((o) => o.value)
const totalsOf = (s: Snapshot) => {
const shown = new Set(visibleFields(sections, s.values).map((f) => f.name))
const read = (name?: string) => (name && shown.has(name) ? num(s.values[name]) : 0)
return computeTotals(s.items, read(fields.discount), read(fields.shipping), taxRate)
}
const totals = totalsOf(history.value)
const count = items.reduce((sum, i) => sum + i.quantity, 0)
const form = useForm<FormValues>({
values,
resetOptions: { keepErrors: true, keepIsSubmitted: true },
resolver: recordFormResolver({ sections }),
})
const { set } = history
useEffect(
() =>
form.subscribe({
formState: { values: true },
callback: ({ values: next, name, type }) => {
if (name && type === 'change')
set((s) => ({ ...s, values: { ...next } }), { merge: name })
},
}),
[form, set],
)
const setValue = (name: string, value: FormValues[string]) =>
set((s) => ({ ...s, values: { ...s.values, [name]: value } }), { merge: name })
const cancel = () => {
history.reset(saved)
form.clearErrors()
setItemsError('')
}
const checkItems = () => {
const error = items.some((i) => !i.name.trim()) ? 'Every item needs a name' : ''
setItemsError(error)
return !error
}
const save = form.handleSubmit(() => {
if (!checkItems()) return
setSaved(history.value)
history.reset()
setJustSaved(true)
setTimeout(() => setJustSaved(false), 1500)
toast.success(`${title} updated`, {
description: notify ? 'The customer was emailed an updated invoice.' : undefined,
})
}, checkItems)
return (
<div className="flex h-svh overflow-hidden bg-sidebar text-foreground">
<Sidebar className="border-r-0 max-lg:hidden">
<SidebarHeader
logo={<span className="size-8 rounded-lg bg-sidebar-primary-foreground" />}
title="Tomei"
subtitle="Store admin"
/>
<SidebarContent>
<SidebarGroup>
<SidebarItem icon={<Home />}>Home</SidebarItem>
<SidebarItem icon={<ShoppingBag />} count={12} active>
Orders
</SidebarItem>
<SidebarItem icon={<Package />}>Products</SidebarItem>
<SidebarItem icon={<Users />}>Customers</SidebarItem>
<SidebarItem icon={<BarChart3 />}>Analytics</SidebarItem>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarItem icon={<Globe />}>Online store</SidebarItem>
</SidebarFooter>
</Sidebar>
<main className="min-w-0 flex-1 overflow-y-auto bg-card lg:my-2 lg:mr-2 lg:rounded-2xl lg:border">
<header className="sticky top-0 z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-3 bg-card/90 px-4 py-3 backdrop-blur">
<div className="flex min-w-0 items-center gap-2 text-sm">
<ShoppingBag className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">Edit {title.toLowerCase()}</span>
{dirty && (
<span className="text-amber-600 max-md:hidden dark:text-amber-400">
· {changes.length} unsaved
</span>
)}
</div>
<div
role="toolbar"
aria-label="Edit actions"
className="flex items-center gap-1 rounded-xl bg-primary p-1 text-primary-foreground shadow-lg"
>
<button
type="button"
disabled={!dirty}
onClick={cancel}
className="h-7 rounded-lg px-3 text-sm font-medium hover:bg-primary-foreground/10 disabled:opacity-40 disabled:hover:bg-transparent"
>
Cancel
</button>
<button
type="button"
disabled={!dirty}
onClick={save}
className={cn(
'h-7 rounded-lg bg-primary-foreground px-3 text-sm font-medium text-primary disabled:opacity-50',
justSaved && 'disabled:opacity-100',
)}
>
{justSaved ? 'Saved ✓' : 'Save'}
</button>
</div>
<div className="flex justify-end gap-1.5">
<Button
size="icon"
variant="ghost"
aria-label="Undo"
title="Undo (Ctrl+Z)"
disabled={!history.canUndo}
onClick={history.undo}
>
<Undo2 />
</Button>
<Button
size="icon"
variant="ghost"
aria-label="Redo"
title="Redo (Ctrl+Shift+Z)"
disabled={!history.canRedo}
onClick={history.redo}
>
<Redo2 />
</Button>
<Button size="icon" aria-label="Previous record" className="max-sm:hidden">
<ChevronUp />
</Button>
<Button size="icon" aria-label="Next record" className="max-sm:hidden">
<ChevronDown />
</Button>
<Button size="icon" variant="ghost" aria-label="Close">
<X />
</Button>
</div>
</header>
<div className="mx-auto flex max-w-[1200px] flex-col gap-8 px-6 pt-4 pb-16">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{badges.map((b) => (
<Badge
key={b.label}
tone={b.tone}
indicator={!b.tone || b.tone === 'neutral' ? 'dot' : 'ring'}
>
{b.label}
</Badge>
))}
</div>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{stageOptions && (
<StagePath
stages={stageOptions}
value={String(values[stageField] ?? '')}
onChange={(stage) => setValue(stageField, stage)}
/>
)}
</div>
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_26rem]">
<div className="flex min-w-0 flex-col gap-8">
<div className="flex flex-col gap-2">
<LineItemEditor
items={items}
original={saved.items}
catalog={catalog}
money={money}
onChange={(next, merge) =>
history.set((s) => ({ ...s, items: next }), merge ? { merge } : undefined)
}
/>
{itemsError && <p className="text-sm text-destructive">{itemsError}</p>}
</div>
<TotalsCard
totals={totals}
before={totalsOf(saved)}
count={count}
paid={paid}
taxRate={taxRate}
discountLabel={fields.code ? String(values[fields.code] ?? '') : undefined}
money={money}
>
<ToggleField
id="notify"
value={notify}
label="Email the customer an updated invoice on save"
onChange={setNotify}
/>
</TotalsCard>
<ChangeList changes={changes} />
</div>
<RecordForm form={form} layout={formLayout} sections={sections} warnOnLeave={false} />
</div>
</div>
</main>
<Toaster />
</div>
)
}import {
diffValues,
type Change,
type FieldTypeMap,
type FormSection,
type FormValues,
} from '@/registry/dashboard/components/record-form/schema'
import type { EditItem } from './line-item-editor'
export function diffRecord(
sections: FormSection[],
before: { values: FormValues; items: EditItem[] },
after: { values: FormValues; items: EditItem[] },
fieldTypes: FieldTypeMap = {},
): Change[] {
const changes = diffValues(sections, before.values, after.values, fieldTypes)
for (const item of after.items) {
const old = before.items.find((i) => i.id === item.id)
const name = item.name || 'Custom item'
if (!old)
changes.push({
id: item.id,
label: name,
from: 'Not on order',
to: `Added × ${item.quantity}`,
})
else {
if (old.quantity !== item.quantity)
changes.push({
id: `${item.id}-qty`,
label: `${name} quantity`,
from: `${old.quantity}`,
to: `${item.quantity}`,
})
if (old.price !== item.price)
changes.push({
id: `${item.id}-price`,
label: `${name} price`,
from: old.price.toFixed(2),
to: item.price.toFixed(2),
})
}
}
for (const old of before.items) {
if (!after.items.some((i) => i.id === old.id))
changes.push({ id: old.id, label: old.name, from: `× ${old.quantity}`, to: 'Removed' })
}
return changes
}import { CreditCard, FileText, MapPin, ShoppingBag, User } from 'lucide-react'
import type {
FormSection,
FormValues,
} from '@/registry/dashboard/components/record-form/record-form'
import type { EditItem, Product } from './line-item-editor'
export const sections: FormSection[] = [
{
id: 'order',
title: 'Order details',
icon: <ShoppingBag />,
columns: 2,
defaultOpen: true,
fields: [
{
name: 'stage',
label: 'Stage',
type: 'select',
required: true,
options: ['Draft', 'Confirmed', 'Invoiced', 'Paid', 'Fulfilled'],
},
{
name: 'owner',
label: 'Owner',
type: 'select',
options: ['Sam Rivera', 'Alex Chen', 'Priya Nair', 'Unassigned'],
},
{ name: 'priority', label: 'Priority', type: 'radio', options: ['Low', 'Normal', 'High'] },
{
name: 'due',
label: 'Delivery date',
type: 'date',
min: 'today',
messages: { min: 'Delivery can’t be in the past' },
},
{
name: 'source',
label: 'Source',
type: 'select',
options: ['Online store', 'Phone', 'Email', 'Sales rep', 'Wholesale portal'],
},
{
name: 'po',
label: 'PO number',
type: 'text',
pattern: '^PO-\\d{4,}$',
placeholder: 'PO-1234',
messages: { pattern: 'Use the format PO-1234', required: 'Wholesale orders need a PO' },
requiredWhen: { field: 'source', is: 'Wholesale portal' },
},
{
name: 'tags',
label: 'Tags',
type: 'multiselect',
creatable: true,
options: ['VIP', 'Wholesale', 'Gift', 'Rush'],
span: 2,
},
{
name: 'giftMessage',
label: 'Gift message',
type: 'textarea',
maxLength: 200,
span: 2,
visibleWhen: { field: 'tags', is: 'Gift' },
},
],
},
{
id: 'customer',
title: 'Customer',
icon: <User />,
columns: 2,
defaultOpen: true,
fields: [
{ name: 'name', label: 'Full name', type: 'text', required: true },
{ name: 'company', label: 'Company', type: 'text' },
{ name: 'email', label: 'Email', type: 'email', required: true },
{ name: 'phone', label: 'Phone', type: 'phone' },
],
},
{
id: 'shipping',
title: 'Shipping address',
icon: <MapPin />,
columns: 2,
fields: [
{ name: 'address', label: 'Address', type: 'text', span: 2 },
{ name: 'city', label: 'City', type: 'text' },
{ name: 'region', label: 'State / region', type: 'text' },
{ name: 'postal', label: 'Postal code', type: 'text' },
{
name: 'country',
label: 'Country',
type: 'select',
options: ['United States', 'Canada', 'United Kingdom', 'Germany', 'Ethiopia'],
},
],
},
{
id: 'pricing',
title: 'Pricing',
icon: <CreditCard />,
columns: 2,
fields: [
{ name: 'code', label: 'Discount code', type: 'text' },
{
name: 'discount',
label: 'Discount (%)',
type: 'number',
min: 0,
max: 100,
visibleWhen: { field: 'code', filled: true },
},
{
name: 'method',
label: 'Shipping method',
type: 'select',
options: ['Standard', 'Express', 'Overnight', 'Local pickup'],
},
{
name: 'shipping',
label: 'Shipping cost',
type: 'currency',
min: 0,
visibleWhen: { field: 'method', not: 'Local pickup' },
},
],
},
{
id: 'notes',
title: 'Notes',
icon: <FileText />,
fields: [
{
name: 'note',
label: 'Internal note',
type: 'textarea',
hint: 'Only your team can see this',
},
],
},
]
export const values: FormValues = {
stage: 'Confirmed',
owner: 'Sam Rivera',
priority: 'Normal',
due: '',
source: 'Online store',
po: '',
tags: ['VIP'],
name: 'Jordan Ellis',
company: '',
email: 'jordan@example.com',
phone: '+1 718 555 0142',
address: '184 Kent Ave',
city: 'Brooklyn',
region: 'NY',
postal: '11249',
country: 'United States',
code: 'WELCOME10',
discount: '10',
method: 'Express',
shipping: '4.99',
note: '',
giftMessage: '',
}
export const totalFields = { discount: 'discount', shipping: 'shipping', code: 'code' }
export const badges: { label: string; tone?: 'neutral' | 'warning' }[] = [
{ label: 'Paid' },
{ label: 'Unfulfilled', tone: 'warning' },
]
export const items: EditItem[] = [
{
id: 'sneakers',
name: 'Canvas Sneakers',
variant: '10.5 US',
price: 235,
quantity: 1,
color: '#d6cfc4',
},
{
id: 'shirt',
name: 'Button-down Shirt',
variant: 'Blue / XL',
price: 65,
quantity: 2,
color: '#3b4a6b',
},
{
id: 'beanie',
name: 'Knit Beanie',
variant: 'One size',
price: 32.5,
quantity: 1,
color: '#6b7a4f',
},
]
export const catalog: Product[] = [
...items.map(({ id, name, variant, price, color }) => ({ id, name, variant, price, color })),
{ id: 'tote', name: 'Canvas Tote', variant: 'Natural', price: 28, color: '#e8dcc2' },
{ id: 'socks', name: 'Wool Socks', variant: '3 pack', price: 24, color: '#8a5a44' },
{ id: 'jacket', name: 'Chore Jacket', variant: 'Olive / L', price: 189, color: '#55613d' },
{ id: 'cap', name: 'Six-panel Cap', variant: 'Navy', price: 38, color: '#1f2a44' },
]'use client'
import { useState } from 'react'
import { Minus, Plus, Search, Trash2, Undo2 } from 'lucide-react'
import { Badge } from '@/registry/dashboard/components/badge/badge'
import { Button } from '@/registry/dashboard/components/button/button'
import { Input } from '@/registry/dashboard/components/input/input'
import { Popover } from '@/registry/dashboard/components/popover/popover'
export type EditItem = {
id: string
name: string
variant?: string
color?: string
price: number
quantity: number
}
export type Product = Omit<EditItem, 'quantity'>
type LineItemEditorProps = {
items: EditItem[]
original: EditItem[]
catalog: Product[]
money: Intl.NumberFormat
onChange: (items: EditItem[], merge?: string) => void
}
function Swatch({ color }: { color?: string }) {
return (
<span
className="block size-11 shrink-0 rounded-lg bg-muted"
style={color ? { background: color } : undefined}
/>
)
}
function PriceInput({
value,
label,
onCommit,
}: {
value: number
label: string
onCommit: (value: number) => void
}) {
const [draft, setDraft] = useState<string | null>(null)
const commit = () => {
if (draft === null) return
const next = Number(draft)
if (!Number.isNaN(next) && next >= 0) onCommit(Math.round(next * 100) / 100)
setDraft(null)
}
return (
<Input
aria-label={label}
inputMode="decimal"
leading="$"
value={draft ?? value.toFixed(2)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
className="w-24"
/>
)
}
function Stepper({
value,
label,
onChange,
}: {
value: number
label: string
onChange: (value: number) => void
}) {
return (
<div
role="group"
aria-label={label}
className="flex h-8 items-center rounded-lg border border-input bg-card"
>
<button
type="button"
aria-label="Decrease"
disabled={value <= 1}
onClick={() => onChange(value - 1)}
className="flex h-full w-7 items-center justify-center text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Minus className="size-3" />
</button>
<span className="w-6 text-center text-sm tabular-nums">{value}</span>
<button
type="button"
aria-label="Increase"
onClick={() => onChange(value + 1)}
className="flex h-full w-7 items-center justify-center text-muted-foreground hover:text-foreground"
>
<Plus className="size-3" />
</button>
</div>
)
}
export function LineItemEditor({ items, original, catalog, money, onChange }: LineItemEditorProps) {
const [query, setQuery] = useState('')
const removed = original.filter((o) => !items.some((i) => i.id === o.id))
const shown = catalog.filter((p) =>
`${p.name} ${p.variant ?? ''}`.toLowerCase().includes(query.toLowerCase()),
)
const patch = (id: string, change: Partial<EditItem>, merge?: string) =>
onChange(
items.map((i) => (i.id === id ? { ...i, ...change } : i)),
merge,
)
const add = (product: Product) => {
const existing = items.find((i) => i.id === product.id)
if (existing) return patch(product.id, { quantity: existing.quantity + 1 })
const before = original.find((o) => o.id === product.id)
onChange([...items, before ?? { ...product, quantity: 1 }])
}
const addCustom = () =>
onChange([
...items,
{ id: `custom-${Date.now().toString(36)}`, name: '', price: 0, quantity: 1 },
])
const status = (item: EditItem) => {
const before = original.find((o) => o.id === item.id)
if (!before) return <Badge tone="success">New</Badge>
if (before.quantity !== item.quantity || before.price !== item.price)
return <Badge tone="attention">Edited</Badge>
return null
}
return (
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold">Line items</h2>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={addCustom}>
Custom item
</Button>
<div>
<Popover
align="end"
className="w-72"
trigger={({ open, toggle }) => (
<Button aria-expanded={open} onClick={toggle}>
<Plus /> Add product
</Button>
)}
>
{(close) => (
<>
<Input
autoFocus
leading={<Search />}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products"
aria-label="Search products"
className="mb-1"
/>
<ul className="max-h-64 overflow-y-auto">
{shown.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => {
add(p)
setQuery('')
close()
}}
className="flex w-full items-center gap-3 rounded-lg p-1.5 text-left hover:bg-muted"
>
<span
className="size-8 shrink-0 rounded-md bg-muted"
style={p.color ? { background: p.color } : undefined}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{p.name}</span>
{p.variant && (
<span className="text-xs text-muted-foreground">{p.variant}</span>
)}
</span>
<span className="text-sm text-muted-foreground tabular-nums">
{money.format(p.price)}
</span>
</button>
</li>
))}
{!shown.length && (
<li className="px-2 py-4 text-center text-sm text-muted-foreground">
No products match
</li>
)}
</ul>
</>
)}
</Popover>
</div>
</div>
</div>
<div className="rounded-xl bg-card shadow-(--shadow-card)">
<ul className="divide-y px-4">
{items.map((item) => (
<li
key={item.id}
className="grid grid-cols-[auto_1fr_auto] items-center gap-x-4 gap-y-2 py-3 sm:grid-cols-[auto_1fr_auto_auto_5.5rem_auto]"
>
<Swatch color={item.color} />
<span className="flex min-w-0 flex-col items-start gap-1.5">
{item.id.startsWith('custom-') ? (
<Input
aria-label="Item name"
autoFocus={!item.name}
placeholder="Item name"
value={item.name}
onChange={(e) => patch(item.id, { name: e.target.value }, `name-${item.id}`)}
/>
) : (
<span className="truncate text-sm font-medium">{item.name}</span>
)}
<span className="flex items-center gap-1.5">
{item.variant && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs">
{item.variant}
</span>
)}
{status(item)}
</span>
</span>
<div className="col-span-2 col-start-2 flex items-center gap-3 max-sm:order-last sm:contents">
<PriceInput
label={`Price of ${item.name || 'item'}`}
value={item.price}
onCommit={(price) => patch(item.id, { price })}
/>
<Stepper
label={`Quantity of ${item.name || 'item'}`}
value={item.quantity}
onChange={(quantity) => patch(item.id, { quantity })}
/>
<span className="ml-auto text-right text-sm tabular-nums sm:ml-0">
{money.format(item.price * item.quantity)}
</span>
</div>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${item.name || 'item'}`}
onClick={() => onChange(items.filter((i) => i.id !== item.id))}
>
<Trash2 />
</Button>
</li>
))}
{removed.map((item) => (
<li
key={item.id}
className="grid grid-cols-[auto_1fr_auto] items-center gap-4 py-3 text-muted-foreground"
>
<span className="opacity-40">
<Swatch color={item.color} />
</span>
<span className="flex min-w-0 flex-col items-start gap-1.5">
<span className="truncate text-sm line-through">{item.name}</span>
<Badge tone="critical">Removed</Badge>
</span>
<Button size="sm" onClick={() => onChange([...items, item])}>
<Undo2 /> Restore
</Button>
</li>
))}
{!items.length && !removed.length && (
<li className="py-8 text-center text-sm text-muted-foreground">
No items yet. Add a product to get started.
</li>
)}
</ul>
</div>
</section>
)
}'use client'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
type StagePathProps = {
stages: string[]
value: string
onChange: (stage: string) => void
className?: string
}
export function StagePath({ stages, value, onChange, className }: StagePathProps) {
const current = stages.indexOf(value)
return (
<ol
aria-label="Stage"
className={cn('flex gap-1 overflow-x-auto rounded-xl bg-muted p-1', className)}
>
{stages.map((stage, i) => {
const done = i < current
const active = i === current
return (
<li key={stage} className="min-w-24 flex-1">
<button
type="button"
aria-current={active ? 'step' : undefined}
onClick={() => onChange(stage)}
className={cn(
'flex h-8 w-full items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium whitespace-nowrap transition-colors',
active && 'bg-primary text-primary-foreground shadow-(--shadow-button-primary)',
done && 'bg-card text-foreground shadow-(--shadow-button)',
!active && !done && 'text-muted-foreground hover:bg-card/60 hover:text-foreground',
)}
>
{done && <Check className="size-3.5" />}
{stage}
</button>
</li>
)
})}
</ol>
)
}import { cn } from '@/lib/utils'
export type Totals = {
subtotal: number
discount: number
shipping: number
tax: number
total: number
}
type TotalsCardProps = {
totals: Totals
before: Totals
count: number
paid: number
discountLabel?: string
taxRate: number
money: Intl.NumberFormat
children?: React.ReactNode
}
export function computeTotals(
items: { price: number; quantity: number }[],
discountPct: number,
shipping: number,
taxRate: number,
): Totals {
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0)
const discount = (subtotal * Math.min(100, Math.max(0, discountPct || 0))) / 100
const ship = Math.max(0, shipping || 0)
const tax = (subtotal - discount) * taxRate
return { subtotal, discount, shipping: ship, tax, total: subtotal - discount + ship + tax }
}
function Row({
label,
detail,
value,
was,
strong,
}: {
label: React.ReactNode
detail?: React.ReactNode
value: string
was?: string
strong?: boolean
}) {
return (
<div
className={cn(
'grid grid-cols-[1fr_auto_7rem] items-baseline gap-4 py-2 text-sm',
strong && 'font-semibold',
)}
>
<span>{label}</span>
<span className="text-right text-muted-foreground">{detail}</span>
<span className="flex flex-col items-end tabular-nums">
{value}
{was && was !== value && (
<span className="text-xs font-normal text-muted-foreground line-through">{was}</span>
)}
</span>
</div>
)
}
export function TotalsCard({
totals,
before,
count,
paid,
discountLabel,
taxRate,
money,
children,
}: TotalsCardProps) {
const f = (n: number) => money.format(n)
const balance = Math.round((totals.total - paid) * 100) / 100
return (
<section className="flex flex-col gap-3">
<h2 className="text-base font-semibold">Payment</h2>
<div className="rounded-xl bg-muted">
<div className="rounded-xl bg-card px-4 py-2 shadow-(--shadow-card)">
<Row
label="Subtotal"
detail={`${count} item${count === 1 ? '' : 's'}`}
value={f(totals.subtotal)}
was={f(before.subtotal)}
/>
{totals.discount > 0 && (
<Row
label="Discount"
detail={discountLabel}
value={`−${f(totals.discount)}`}
was={before.discount ? `−${f(before.discount)}` : undefined}
/>
)}
<Row label="Shipping" value={f(totals.shipping)} was={f(before.shipping)} />
<Row
label="Tax"
detail={`${Math.round(taxRate * 1000) / 10}%`}
value={f(totals.tax)}
was={f(before.tax)}
/>
<div className="mt-1 border-t pt-1">
<Row label="Total" value={f(totals.total)} was={f(before.total)} strong />
</div>
</div>
<div className="px-4 py-2">
<Row label="Paid by customer" value={f(paid)} />
<Row
strong
label={balance > 0 ? 'Amount to collect' : balance < 0 ? 'Refund owed' : 'Balance'}
value={f(Math.abs(balance))}
/>
{children && <div className="border-t py-3">{children}</div>}
</div>
</div>
</section>
)
}