ui fixers in 10/16

This commit is contained in:
2026-10-16 14:34:28 +05:30
parent 379ad204bd
commit e289b8430f
47 changed files with 1904 additions and 1046 deletions
@@ -20,15 +20,12 @@ import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/** What the edit dialog is editing — a category (no GL code) or a title. */
type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null
export default function ExpenseSetupPage() {
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [addTitleOpen, setAddTitleOpen] = useState(false)
const [editing, setEditing] = useState<Editing>(null)
const [editing, setEditing] = useState<ExpenseTitle | null>(null)
function load() {
expenseSetupApi
@@ -45,10 +42,10 @@ export default function ExpenseSetupPage() {
const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Expense Setup</h1>
<p className="text-base text-muted-foreground">
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Expense Setup</h1>
<p className="text-sm text-muted-foreground sm:text-base">
The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.
</p>
</div>
@@ -58,19 +55,22 @@ export default function ExpenseSetupPage() {
{categories && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[16rem_1fr]">
<nav className="flex gap-2 overflow-x-auto lg:flex-col" aria-label="Expense categories">
{/* Phones: the (fixed, four) categories as a 2×2 grid — no sideways scrolling to find one.
Tablets: one scrollable row. Desktop: a side list. */}
<nav className="grid grid-cols-2 gap-2 sm:flex sm:overflow-x-auto lg:flex-col" aria-label="Expense categories">
{categories.map((c) => (
<button
key={c.expenseCategoryId}
onClick={() => setSelectedId(c.expenseCategoryId)}
aria-current={c.expenseCategoryId === selectedId ? "true" : undefined}
className={cn(
"flex shrink-0 items-center justify-between gap-2 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
"flex min-h-12 min-w-0 shrink-0 items-center justify-between gap-2 rounded-xl border px-3 py-3 text-left text-sm transition-colors sm:px-4",
c.expenseCategoryId === selectedId ? "border-primary bg-primary/5 text-foreground" : "bg-card text-muted-foreground hover:text-foreground",
c.status === "Inactive" && "opacity-60"
)}
>
<span className="font-medium">{c.name}</span>
<span className="text-xs tabular-nums">{c.titles.length}</span>
<span className="min-w-0 truncate font-medium">{c.name}</span>
<span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums">{c.titles.length}</span>
</button>
))}
</nav>
@@ -85,16 +85,10 @@ export default function ExpenseSetupPage() {
{selected.isSystem && <SystemBadge />}
{selected.status === "Inactive" && <InactiveBadge />}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setEditing({ kind: "category", row: selected })}>
<Pencil className="size-4" />
Edit category
</Button>
<Button size="sm" onClick={() => setAddTitleOpen(true)}>
<Plus className="size-4" />
Add title
</Button>
</div>
<Button size="sm" className="h-10 w-full sm:h-8 sm:w-auto" onClick={() => setAddTitleOpen(true)}>
<Plus className="size-4" />
Add title
</Button>
</div>
{selected.code === "SALARIES" && (
@@ -106,7 +100,36 @@ export default function ExpenseSetupPage() {
{selected.titles.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">No titles yet — add the first one.</p>
) : (
<div className="overflow-x-auto">
<>
{/* Phones: one card per title — tap anywhere on it to edit. */}
<ul className="flex flex-col gap-2 p-3 md:hidden">
{selected.titles.map((t) => (
<li key={t.expenseTitleId}>
<button
type="button"
onClick={() => setEditing(t)}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-3 text-left hover:bg-muted/40",
t.status === "Inactive" && "opacity-60"
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 font-medium">
<span className="truncate">{t.name}</span>
{t.isSystem && <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Built-in" />}
</span>
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono tabular-nums">GL {t.glExpenseAccountCode}</span>
{t.status === "Inactive" && <InactiveBadge />}
</span>
</span>
<Pencil className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="sr-only">Edit {t.name}</span>
</button>
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -128,7 +151,7 @@ export default function ExpenseSetupPage() {
<TableCell className="px-4 py-3 tabular-nums">{t.glExpenseAccountCode}</TableCell>
<TableCell className="px-4 py-3">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell>
<TableCell className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setEditing({ kind: "title", row: t })}>
<Button variant="ghost" size="sm" onClick={() => setEditing(t)}>
<Pencil className="size-4" />
Edit
</Button>
@@ -138,6 +161,7 @@ export default function ExpenseSetupPage() {
</TableBody>
</Table>
</div>
</>
)}
</div>
)}
@@ -179,7 +203,7 @@ function InactiveBadge() {
)
}
function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: () => void; onSaved: () => void }) {
function EditDialog({ editing, onClose, onSaved }: { editing: ExpenseTitle | null; onClose: () => void; onSaved: () => void }) {
const [name, setName] = useState("")
const [glCode, setGlCode] = useState("")
const [active, setActive] = useState(true)
@@ -187,33 +211,29 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
const [saving, setSaving] = useState(false)
// Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs.
const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
const key = editing?.expenseTitleId ?? null
const [resetFor, setResetFor] = useState<number | null>(null)
if (key !== resetFor) {
setResetFor(key)
if (editing) {
setName(editing.row.name)
setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "")
setActive(editing.row.status === "Active")
setName(editing.name)
setGlCode(editing.glExpenseAccountCode)
setActive(editing.status === "Active")
setError(null)
}
}
if (!editing) return null
const isSystem = editing.row.isSystem
const isSystem = editing.isSystem
async function submit() {
if (!editing) return
if (!name.trim()) return setError("Enter a name")
if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code")
if (!glCode.trim()) return setError("Enter the GL expense account code")
const status: EntityStatus = active ? "Active" : "Inactive"
setSaving(true)
try {
if (editing.kind === "category") {
await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status })
} else {
await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
}
await expenseSetupApi.updateTitle(editing.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
toast.success("Saved", name.trim())
onSaved()
onClose()
@@ -228,7 +248,7 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit {editing.kind === "category" ? "category" : "title"}</DialogTitle>
<DialogTitle>Edit title</DialogTitle>
</DialogHeader>
<FieldGroup>
<Field>
@@ -236,13 +256,11 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
<Input id="edit-name" value={name} maxLength={100} disabled={isSystem} onChange={(e) => setName(e.target.value)} />
{isSystem && <FieldDescription>Built-in names can&apos;t be changed.</FieldDescription>}
</Field>
{editing.kind === "title" && (
<Field>
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
<Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
<FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
<Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
<FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="edit-active" checked={active} onCheckedChange={setActive} />
<FieldLabel htmlFor="edit-active">Active — offered on new expenses</FieldLabel>
@@ -250,10 +268,10 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose:
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button variant="outline" className="h-11 sm:h-9" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
<Button className="h-11 sm:h-9" onClick={submit} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
@@ -236,11 +236,13 @@ export default function CustomersPage() {
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New customer</DialogTitle>
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
</DialogHeader>
{/* Only the fields scroll — title and Create/Cancel stay visible on a phone. */}
<div className="-mx-4 min-h-0 flex-1 overflow-y-auto px-4 pb-1">
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="c-name" required>Name</FieldLabel>
@@ -263,10 +265,10 @@ export default function CustomersPage() {
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
<Input id="c-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
<Input id="c-phone" type="tel" inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
<Field>
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
@@ -281,7 +283,7 @@ export default function CustomersPage() {
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
</Field>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
@@ -291,7 +293,7 @@ export default function CustomersPage() {
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
@@ -318,11 +320,12 @@ export default function CustomersPage() {
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
</div>
<div className="-mx-4 flex shrink-0 flex-col-reverse gap-3 border-t px-4 pt-3 sm:mx-0 sm:flex-row sm:justify-center sm:border-t-0 sm:px-0 sm:pt-2">
<Button variant="outline" className="h-11 w-full sm:h-9 sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
<Button className="h-11 w-full sm:h-9 sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
@@ -0,0 +1,93 @@
"use client"
import Link from "next/link"
import { Banknote, CalendarCheck, CalendarClock, IdCard, SlidersHorizontal, Users } from "lucide-react"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button"
// Same entries (and permission codes) as the HRM group in the sidebar.
const sections = [
{
title: "Employees",
description: "Employee records, departments and designations.",
href: "/dashboard/hrm/employees",
code: "hrm.employees",
icon: Users,
},
{
title: "Attendance",
description: "Daily attendance and time records.",
href: "/dashboard/hrm/attendance",
code: "hrm.attendance",
icon: CalendarCheck,
},
{
title: "Leave",
description: "Leave requests, approvals and balances.",
href: "/dashboard/hrm/leave",
code: "hrm.leave",
icon: CalendarClock,
},
{
title: "Payroll",
description: "Salary runs and payslips.",
href: "/dashboard/hrm/payroll",
code: "hrm.payroll",
icon: Banknote,
},
{
title: "Settings",
description: "HRM masters and configuration.",
href: "/dashboard/hrm/settings",
code: "hrm.settings",
icon: SlidersHorizontal,
},
]
/** HRM overview — what the breadcrumb's "HRM" opens; one card per HRM section the user can see. */
export default function HrmHubPage() {
const { navCodes, isAdmin, loading } = useAuth()
// Admins see everything; otherwise the same nav codes that decide the sidebar's HRM entries.
const visible = loading ? [] : sections.filter((s) => isAdmin || navCodes.includes(s.code))
return (
<div className="flex flex-col gap-6">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
<IdCard className="size-4" />
HRM
</div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">HRM</h1>
<p className="text-sm text-muted-foreground sm:text-base">Employees, attendance, leave and payroll in one place.</p>
</div>
{!loading && visible.length === 0 && (
<p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
You don&apos;t have access to any HRM sections.
</p>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((section) => {
const Icon = section.icon
return (
<Link
key={section.href}
href={section.href}
className="group rounded-2xl border bg-card p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<div className="mb-4 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{section.description}</p>
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "mt-4 px-0 text-primary")}>Open</div>
</Link>
)
})}
</div>
</div>
)
}
+32 -19
View File
@@ -34,6 +34,7 @@ import BarChart from "@/components/ui/bar-chart"
import LineChart from "@/components/ui/line-chart"
const TREND_DAYS = 14
const MOBILE_TREND_DAYS = 5
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
const CHART_MIN_SLOTS = 5
@@ -112,6 +113,16 @@ export default function DashboardPage() {
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
const isMobile = useIsMobile()
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
const shownTrend = useMemo(
() => ({
labels: movementTrend.labels.slice(-trendDays),
inData: movementTrend.inData.slice(-trendDays),
outData: movementTrend.outData.slice(-trendDays),
}),
[movementTrend, trendDays]
)
const valuationRows = stats?.stockValuationByWarehouse ?? []
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
@@ -130,26 +141,28 @@ export default function DashboardPage() {
</div>
)}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
{/* Three across until there is room for six — at six-up the cards get too narrow and switch to the
compact stacked layout, so they would not match the stat cards on every other screen. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
{loaded ? (
<>
<Link href="/dashboard/stock/reorder-alerts">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
<Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
</Link>
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
<Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
<Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
</Link>
<Link href="/dashboard/procurement/requisitions">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
<Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
</Link>
<Link href="/dashboard/stock/counts">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
<Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
</Link>
<Link href="/dashboard/procurement/rfqs">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
<Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
</Link>
</>
) : (
@@ -199,23 +212,23 @@ export default function DashboardPage() {
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
Stock Movement Trend (last {trendDays} days)
</h2>
</div>
{loaded ? (
movementTrend.inData.some((v) => v > 0) || movementTrend.outData.some((v) => v > 0) ? (
shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
<div className="h-64 sm:h-72">
<LineChart
labels={movementTrend.labels}
labels={shownTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
{ label: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {TREND_DAYS} days.</p>
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {trendDays} days.</p>
)
) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
@@ -230,7 +230,7 @@ function NewPurchaseReturnContent() {
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Goods Receipt Note</Label>
<Label className="text-base">Goods Received Note</Label>
<Combobox<number>
items={grnOptions}
value={grnId}
@@ -63,6 +63,8 @@ import {
ITEM_IMAGE_MAX_COUNT,
itemImageProblem,
} from "@/components/products/ItemImagesPanel"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
function skuSegment(text: string, maxLen: number): string {
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
@@ -118,6 +120,8 @@ export default function NewItemPage() {
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
/** Defaults to the first UOM once loaded; null only means none exist yet. */
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [addUomOpen, setAddUomOpen] = useState(false)
const [addBrandOpen, setAddBrandOpen] = useState(false)
// Kept as a raw string: an empty box means "no content size", which is different from 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
@@ -216,7 +220,11 @@ export default function NewItemPage() {
setBaseUomId(uoms.items[0]?.uomId ?? null)
setWarehouses(wh.items)
})
.catch((err) => setLoadError(errorMessage(err)))
.catch((err) => {
const message = errorMessage(err)
setLoadError(message)
toast.error("Could not load the form", message)
})
// Optional extra: a failure here just means only the default periods are offered.
warrantyPeriodsApi
.list({ status: "Active" })
@@ -350,6 +358,7 @@ export default function NewItemPage() {
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
if (parsed.error !== undefined) {
setCustomPeriodError(parsed.error)
toast.error("Invalid warranty period", parsed.error)
return
}
setItemOnlyWarrantyMonths(parsed.months)
@@ -375,24 +384,30 @@ export default function NewItemPage() {
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
*/
/** Inline message under the custom-variant box, mirrored as a toast. */
function failCustom(message: string) {
setCustomError(message)
toast.error("Could not add custom variant", message)
}
function createCustomVariant() {
const name = customName.trim() || (customIsSize ? "Size" : "")
const invalid = validateItemTypeName(name).name
if (invalid) {
setCustomError(invalid)
failCustom(invalid)
return
}
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
setCustomError(`"${name}" already exists — tick it above instead.`)
failCustom(`"${name}" already exists — tick it above instead.`)
return
}
if (customIsSize && !measurementUnit) {
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.")
failCustom("Pick a Measurement unit at the top first — size values are entered in it.")
return
}
// Only one dimension may supply the content size, or a variant would have two.
if (customIsSize && measurableCheckedCount > 0) {
setCustomError("Only one size variant can be used at a time — untick the other one first.")
failCustom("Only one size variant can be used at a time — untick the other one first.")
return
}
const local: ItemType = {
@@ -413,6 +428,10 @@ export default function NewItemPage() {
* duplicate pair never reaches state, so nothing downstream has to defend against one.
*/
function addValue(itemTypeId: number) {
const failValue = (message: string) => {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: message }))
toast.error("Could not add value", message)
}
const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false
if (!measurable) {
@@ -435,11 +454,11 @@ export default function NewItemPage() {
const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" }))
failValue("Enter a size greater than 0")
return
}
if (!unit) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" }))
failValue("Select a unit")
return
}
@@ -448,7 +467,7 @@ export default function NewItemPage() {
const key = measureKey(qty, unit)
const existing = valuesByCategory[itemTypeId] ?? []
if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) {
setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" }))
failValue("That size is already in the list")
return
}
@@ -708,13 +727,44 @@ export default function NewItemPage() {
const loading = !categories || !brands || !itemTypes || !config
/** One price cell, shared by the mobile variant cards and the desktop table. Flat ("") is the validated one. */
function priceInput(variantKey: string, kind: "min" | "max" | "flat", className?: string) {
const stateKey = kind === "flat" ? variantKey : `${variantKey}::${kind}`
const invalid = kind === "flat" && !!priceErrors[variantKey]
return (
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[stateKey] ?? ""}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [stateKey]: value }))
if (kind === "flat")
setPriceErrors((prev) => {
if (!prev[variantKey]) return prev
const next = { ...prev }
delete next[variantKey]
return next
})
}}
placeholder="0.00"
aria-label={`${kind === "flat" ? "Flat" : kind === "min" ? "Min" : "Max"} price`}
aria-invalid={invalid}
className={cn("h-10 text-base", className)}
/>
)
}
/** Mobile-first section shell: tighter padding on phones, roomier from `sm` up. */
const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p>
</div>
<div className="flex flex-col gap-5 sm:gap-6">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">New Item</h1>
<p className="text-sm text-muted-foreground sm:text-base">Category, subcategory, brand, and item variants (FR-MD-01).</p>
</div>
{loadError && (
@@ -726,17 +776,19 @@ export default function NewItemPage() {
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM —{" "}
<Link href="/dashboard/products/categories?tab=uom" className="font-semibold underline">
create one first
</Link>
<button type="button" onClick={() => setAddUomOpen(true)} className="font-semibold underline">
add one now
</button>
.
</div>
)}
{!loading && (
<>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Basic details</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base" required>Product name</Label>
<Input
value={productName}
@@ -802,108 +854,72 @@ export default function NewItemPage() {
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<div className="flex gap-2">
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
>
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
className="h-12 shrink-0"
onClick={() => setAddBrandOpen(true)}
aria-label="Add brand"
>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
</div>
</div>
)}
</div>
</section>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Units &amp; stock</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base" required>Base UOM</Label>
<div className="flex gap-2">
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
value={baseUomId}
onValueChange={setBaseUomId}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base">
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
className="h-12 shrink-0"
onClick={() => setAddUomOpen(true)}
aria-label="Add UOM"
>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={lowStockAt}
onChange={(e) => {
setLowStockAt(e.target.value)
clearErrors("lowStockAt", "reorderQty")
}}
placeholder="Alert when stock ≤"
aria-label="Alert when stock is at or below"
aria-invalid={!!errors.lowStockAt}
className="h-12! text-base"
/>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={reorderQty}
onChange={(e) => {
setReorderQty(e.target.value)
clearErrors("reorderQty")
}}
placeholder="Reorder qty"
aria-label="Reorder quantity"
aria-invalid={!!errors.reorderQty}
className="h-12! text-base"
/>
</div>
<FieldError
errors={[
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
errors.reorderQty ? { message: errors.reorderQty } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
{lowStockAt.trim()
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base" required>Base UOM</Label>
<Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* The unit size variants are measured in. Picked once here; every size value added
below (custom size variant or a measurement Item Variant) starts in this unit. */}
@@ -1026,11 +1042,88 @@ export default function NewItemPage() {
</p>
</div>
</div>
</section>
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
<p className="text-sm text-muted-foreground">Optional — get notified when stock runs low.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Alert when stock ≤ / reorder qty</Label>
{/* Stays side by side even on phones: two short numbers read as one rule. */}
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={lowStockAt}
onChange={(e) => {
setLowStockAt(e.target.value)
clearErrors("lowStockAt", "reorderQty")
}}
placeholder="Alert at"
aria-label="Alert when stock is at or below"
aria-invalid={!!errors.lowStockAt}
className="h-12! text-base"
/>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={reorderQty}
onChange={(e) => {
setReorderQty(e.target.value)
clearErrors("reorderQty")
}}
placeholder="Reorder qty"
aria-label="Reorder quantity"
aria-invalid={!!errors.reorderQty}
className="h-12! text-base"
/>
</div>
<FieldError
errors={[
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
errors.reorderQty ? { message: errors.reorderQty } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
{lowStockAt.trim()
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div>
</div>
</section>
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground">
@@ -1054,8 +1147,8 @@ export default function NewItemPage() {
</RadioGroup>
{priceMode === "fixed" && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex flex-col gap-2 sm:min-w-56 sm:flex-1">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
@@ -1071,6 +1164,7 @@ export default function NewItemPage() {
<Button
type="button"
variant="outline"
className="h-11 w-full sm:w-auto"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
@@ -1096,7 +1190,7 @@ export default function NewItemPage() {
)}
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
@@ -1140,7 +1234,7 @@ export default function NewItemPage() {
value: m,
}))}
>
<SelectTrigger className="h-11! w-60 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectTrigger className="h-11! w-full text-base sm:w-60" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
@@ -1253,7 +1347,7 @@ export default function NewItemPage() {
)}
{config?.itemImagesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
@@ -1275,7 +1369,7 @@ export default function NewItemPage() {
type="button"
onClick={() => removePendingImage(p.url)}
aria-label={`Remove ${p.file.name}`}
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
className="absolute top-1.5 right-1.5 flex size-8 items-center sm:size-6 justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
>
<X className="size-3.5" />
</button>
@@ -1309,7 +1403,7 @@ export default function NewItemPage() {
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
<p className="text-sm text-muted-foreground">
@@ -1329,7 +1423,7 @@ export default function NewItemPage() {
)}
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{(itemTypes ?? []).map((t) => {
const checked = checkedItemTypeIds.includes(t.itemTypeId)
// Only one dimension may supply the content size, or a variant would have two.
@@ -1339,7 +1433,7 @@ export default function NewItemPage() {
key={t.itemTypeId}
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
className={cn(
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
"flex min-h-11 min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
blocked ? "opacity-50" : "hover:bg-muted/50"
)}
>
@@ -1362,7 +1456,7 @@ export default function NewItemPage() {
})}
{config?.customVariantsEnabled && !customOpen && (
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
<Button type="button" variant="outline" className="h-11" onClick={() => setCustomOpen(true)}>
<Plus className="size-4" />
Custom variant
</Button>
@@ -1414,11 +1508,11 @@ export default function NewItemPage() {
/>
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
</label>
<div className="flex gap-2 sm:ml-auto">
<Button type="button" variant="ghost" onClick={closeCustomVariant}>
<div className="grid grid-cols-2 gap-2 sm:ml-auto sm:flex">
<Button type="button" variant="ghost" className="h-11 sm:h-9" onClick={closeCustomVariant}>
Cancel
</Button>
<Button type="button" onClick={createCustomVariant}>
<Button type="button" className="h-11 sm:h-9" onClick={createCustomVariant}>
Add
</Button>
</div>
@@ -1490,9 +1584,15 @@ export default function NewItemPage() {
</SelectContent>
</Select>
)}
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Button
type="button"
variant="outline"
className="h-11 shrink-0"
onClick={() => addValue(t.itemTypeId)}
aria-label={`Add ${t.name}`}
>
<Plus className="size-4" />
Add
<span className="hidden sm:inline">Add</span>
</Button>
</div>
) : (
@@ -1509,9 +1609,15 @@ export default function NewItemPage() {
placeholder={t.name}
className="h-11 text-base"
/>
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Button
type="button"
variant="outline"
className="h-11 shrink-0"
onClick={() => addValue(t.itemTypeId)}
aria-label={`Add ${t.name}`}
>
<Plus className="size-4" />
Add {t.name}
<span className="hidden sm:inline">Add {t.name}</span>
</Button>
</div>
)}
@@ -1525,7 +1631,7 @@ export default function NewItemPage() {
<button
type="button"
onClick={() => removeValue(t.itemTypeId, v.label)}
className="rounded-full p-0.5 hover:bg-muted"
className="rounded-full p-1 hover:bg-muted sm:p-0.5"
aria-label={`Remove ${v.label}`}
>
<X className="size-3" />
@@ -1544,111 +1650,117 @@ export default function NewItemPage() {
<FieldError errors={[errors.variantContent ? { message: errors.variantContent } : undefined]} />
{variants.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
{activeCategories.map((cat) => (
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
<>
{/* Phones: one card per variant — a 6+ column table can't fit a narrow screen. */}
<ul className="flex flex-col gap-3 md:hidden">
{variants.map((variant) => (
<li key={variant.key} className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-base font-semibold wrap-break-word">
{variant.parts.map((p) => p.value.label).join(" / ")}
</p>
<p className="font-mono text-sm break-all text-muted-foreground">{variant.sku}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeVariant(variant.key)}
aria-label={`Remove ${variant.sku}`}
className="-mt-1 -mr-1 shrink-0 text-destructive hover:bg-destructive/10"
>
<X className="size-4" />
</Button>
</div>
<p className="text-sm">
<span className="text-muted-foreground">Content: </span>
{contentLabelFor(variant)}
</p>
{priceMode === "fixed" && (
<>
<TableHead className="h-11 px-3 text-sm">Min price</TableHead>
<TableHead className="h-11 px-3 text-sm">Max price</TableHead>
<TableHead className="h-11 px-3 text-sm">Flat price</TableHead>
</>
<div className="flex flex-col gap-1">
<div className="grid grid-cols-3 gap-2">
{(["min", "max", "flat"] as const).map((kind) => (
<div key={kind} className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
{kind === "min" ? "Min" : kind === "max" ? "Max" : "Flat"}
</span>
{priceInput(variant.key, kind, "w-full")}
</div>
))}
</div>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</div>
)}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
field that silently discards what you type. */}
<TableHead className="h-11 w-8 px-1" />
</TableRow>
</TableHeader>
<TableBody>
{variants.map((variant) => (
<TableRow key={variant.key}>
{variant.parts.map((part, i) => (
<TableCell key={i} className="px-3 py-2.5">
{part.value.label}
</TableCell>
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
{activeCategories.map((cat) => (
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5">
{contentLabelFor(variant)}
</TableCell>
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::min`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::min`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::max`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::max`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
<TableHead className="h-11 px-3 text-sm">Min price</TableHead>
<TableHead className="h-11 px-3 text-sm">Max price</TableHead>
<TableHead className="h-11 px-3 text-sm">Flat price</TableHead>
</>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeVariant(variant.key)}
aria-label={`Remove ${variant.sku}`}
className="text-destructive hover:bg-destructive/10"
>
<X className="size-4" />
</Button>
</TableCell>
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
field that silently discards what you type. */}
<TableHead className="h-11 w-8 px-1" />
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{variants.map((variant) => (
<TableRow key={variant.key}>
{variant.parts.map((part, i) => (
<TableCell key={i} className="px-3 py-2.5">
{part.value.label}
</TableCell>
))}
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
{/* Read-only: content is derived, so an editable cell here could only
disagree with the value that produced it. */}
<TableCell className="px-3 py-2.5">
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
<>
<TableCell className="px-3 py-2.5">{priceInput(variant.key, "min", "w-28")}</TableCell>
<TableCell className="px-3 py-2.5">{priceInput(variant.key, "max", "w-28")}</TableCell>
<TableCell className="px-3 py-2.5">
{priceInput(variant.key, "flat", "w-28")}
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
</>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeVariant(variant.key)}
aria-label={`Remove ${variant.sku}`}
className="text-destructive hover:bg-destructive/10"
>
<X className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
)}
@@ -1657,16 +1769,40 @@ export default function NewItemPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
{/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */}
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:flex-row sm:items-center sm:justify-end">
{variants.length > 0 && (
<p className="text-center text-sm text-muted-foreground sm:mr-auto sm:text-left">
{variants.length} variant{variants.length === 1 ? "" : "s"} will be created
</p>
)}
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
{submitting ? "Creating…" : "Create Variants"}
</Button>
</div>
</>
)}
<AddUomDialog
open={addUomOpen}
onOpenChange={setAddUomOpen}
onCreated={(uom) => {
setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }])
setBaseUomId(uom.uomId)
setSubmitError(null)
}}
/>
<AddBrandDialog
open={addBrandOpen}
onOpenChange={setAddBrandOpen}
onCreated={(brand) => {
setBrands((prev) => [...(prev ?? []), brand])
setBrandId(brand.brandId)
}}
/>
</div>
)
}
@@ -33,6 +33,7 @@ import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory,
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
@@ -187,7 +188,9 @@ export default function ItemsPage() {
if (!append) setSelected(new Set())
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
if (cancelled) return
setError(errorMessage(err))
toast.error("Could not load items", errorMessage(err))
})
.finally(() => {
if (!cancelled) setLoadingMore(false)
@@ -344,19 +347,30 @@ export default function ItemsPage() {
<h1 className="text-2xl font-bold text-foreground">Items</h1>
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
{/* New Item is the page's primary action: first and full-width on phones, last (rightmost) and
emphasised on desktop; the secondary actions share a row beneath it on mobile. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
<Link
href="/dashboard/products/new"
className={cn(
buttonVariants({ size: "lg" }),
"order-first col-span-2 h-12 text-base font-semibold shadow-md shadow-primary/25 sm:order-last sm:h-11 sm:px-6"
)}
>
<Plus className="size-5" />
New Item
</Link>
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "min-w-0")}>
<ListTree className="size-5" />
Category/Brand/UOM
<span className="truncate">
<span className="sm:hidden">Masters</span>
<span className="hidden sm:inline">Category/Brand/UOM</span>
</span>
</Link>
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-5" />
Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Item
</Link>
</div>
</div>
@@ -475,7 +489,7 @@ export default function ItemsPage() {
<Package className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
{!hasFilters && (
<Link href="/dashboard/products/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }), "shadow-md shadow-primary/25")}>
<Plus className="size-5" />
New Item
</Link>
@@ -23,14 +23,15 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { SerialRanges } from "@/components/receiving/SerialRanges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */
/** Batch (with expiry), serial and warranty numbers of one GRN line — numbers shown as From – To ranges. */
function LineTrackingInfo({ line }: { line: GrnLine }) {
const serials = line.serials ?? []
if (!line.batchNo && serials.length === 0) return null
if (!line.batchNo && serials.length === 0 && line.warrantyNumbers.length === 0) return null
return (
<div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground">
{line.batchNo && (
@@ -39,18 +40,8 @@ function LineTrackingInfo({ line }: { line: GrnLine }) {
{line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>}
</p>
)}
{serials.length > 0 && (
<details>
<summary className="cursor-pointer select-none">
{serials.length} serial number{serials.length === 1 ? "" : "s"}
</summary>
<ul className="mt-1 grid max-h-48 grid-cols-2 gap-x-4 overflow-y-auto font-mono">
{serials.map((s) => (
<li key={s.serialNo}>{s.serialNo}</li>
))}
</ul>
</details>
)}
<SerialRanges label="serial number" values={serials.map((s) => s.serialNo)} />
<SerialRanges label="warranty number" values={line.warrantyNumbers.map((w) => w.warrantyNo)} />
</div>
)
}
@@ -112,7 +103,13 @@ export default function GrnDetailPage() {
useEffect(() => {
if (!Number.isFinite(grnId)) return
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
grnsApi
.get(grnId)
.then(setGrn)
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load GRN", errorMessage(err))
})
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()])
.then(([it, uo]) => {
setItems(it.items)
@@ -258,11 +255,6 @@ export default function GrnDetailPage() {
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
</div>
{line.warrantyNumbers.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
<LineTrackingInfo line={line} />
</div>
<HoldStatusBadge status={line.holdStatus} />
@@ -378,14 +370,6 @@ export default function GrnDetailPage() {
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
</div>
{line.warrantyNumbers.length > 0 && (
<p
className="mt-1 text-xs text-muted-foreground"
title={line.warrantyNumbers.map((w) => `${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")}
>
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
<LineTrackingInfo line={line} />
</TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
@@ -289,7 +289,10 @@ export default function NewGrnPage() {
setVendors(ve.items)
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
})
.catch((err) => setLoadError(errorMessage(err)))
.catch((err) => {
setLoadError(errorMessage(err))
toast.error("Could not load the GRN form", errorMessage(err))
})
}, [])
useEffect(() => {
@@ -318,6 +321,27 @@ export default function NewGrnPage() {
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = warehouses ?? []
// Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen.
function failHeader(message: string) {
setHeaderError(message)
toast.error("Check the GRN details", message)
}
function failScan(message: string) {
setScanError(message)
toast.error("Scan failed", message)
}
function failSubmit(message: string) {
setSubmitError(message)
toast.error("Could not create GRN", message)
}
function failWarranty(lineKey: string, message: string) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: message }))
toast.error("Could not fill warranty numbers", message)
}
function switchMode(next: Mode) {
setMode(next)
setPoId(null)
@@ -342,7 +366,7 @@ export default function NewGrnPage() {
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
if (openLines.length === 0) {
setHeaderError("This purchase order has no open (unreceived) lines.")
failHeader("This purchase order has no open (unreceived) lines.")
setLines([])
return
}
@@ -369,7 +393,7 @@ export default function NewGrnPage() {
})
)
} catch (err) {
setHeaderError(errorMessage(err))
failHeader(errorMessage(err))
} finally {
setPoLoading(false)
}
@@ -403,9 +427,9 @@ export default function NewGrnPage() {
addItemLine(result.itemId, scannedBatchNo)
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`)
failScan(`No item or lot found for "${code}"`)
} else {
setScanError(errorMessage(err))
failScan(errorMessage(err))
}
} finally {
setScanning(false)
@@ -526,7 +550,7 @@ export default function NewGrnPage() {
function replaceWarrantyNumbers(lineKey: string, units: number) {
const range = warrantyRangeInputs[lineKey]
if (!range?.from.trim() || !range?.adding.trim()) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" }))
failWarranty(lineKey, "Enter both a from number and an adding number")
return
}
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
@@ -536,14 +560,11 @@ export default function NewGrnPage() {
const to = applyAddingNumber(from, range.adding)
const seq = buildWarrantyRange(from, to)
if (!seq) {
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" }))
failWarranty(lineKey, "The computed number must share From's prefix and not come before it")
return
}
if (seq.length > units) {
setWarrantyAddErrors((prev) => ({
...prev,
[lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`,
}))
failWarranty(lineKey, `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`)
return
}
setWarrantyAddErrors((prev) => {
@@ -636,23 +657,23 @@ export default function NewGrnPage() {
poId: mode === "po" ? poId : null,
})
if (!header.success) {
setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.")
failHeader(header.error.issues[0]?.message ?? "Check the header fields.")
return
}
if (mode === "direct" && !vendorId) {
setHeaderError("Select a vendor for a direct receipt.")
failHeader("Select a vendor for a direct receipt.")
return
}
if (mode === "po" && !poId) {
setHeaderError("Select a purchase order.")
failHeader("Select a purchase order.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this GRN.")
failHeader("Select a branch for this GRN.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
failSubmit("Add at least one line.")
return
}
@@ -680,6 +701,12 @@ export default function NewGrnPage() {
)
setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
setSubmitError("Fix the highlighted lines before submitting.")
// The toast names the first problem so it's useful even when that line is scrolled out of view.
const messages = [...new Set(Object.values(nextLineErrors).flatMap((errs) => Object.values(errs)))]
toast.error(
"Fix the highlighted lines before submitting",
messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`,
)
return
}
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
@@ -97,7 +98,10 @@ export default function GrnListPage() {
setGrns(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.catch((err) => {
setError(errorMessage(err))
toast.error("Could not load GRNs", errorMessage(err))
})
.finally(() => setShowMoreLoading(false))
}
@@ -114,7 +118,7 @@ export default function GrnListPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Goods Receipt Notes</h1>
<h1 className="text-2xl font-bold text-foreground">Goods Received Notes</h1>
<p className="text-base text-muted-foreground">
Receive goods against a purchase order, or record a direct receipt.
</p>
@@ -22,6 +22,7 @@ import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination"
import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
@@ -30,6 +31,7 @@ import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -71,6 +73,20 @@ function ProfitCell({ value, bold = false }: { value: number; bold?: boolean })
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)}>
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="truncate font-mono text-sm tabular-nums">{children}</span>
</div>
)
}
/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */
const mobileCard = (total = false) =>
cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold")
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
function ProfitStatement({ day }: { day: DayEndReportDay }) {
const gross = profitOrLoss(day.grossProfit)
@@ -112,6 +128,7 @@ function ProfitStatement({ day }: { day: DayEndReportDay }) {
* banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module.
*/
function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) {
const depositPage = useClientPagination(deposits, date)
const remaining = day.closingCashInHand ?? 0
return (
<div className="flex flex-col gap-2">
@@ -125,21 +142,15 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
<StatCard label="Opening cash in hand" value={formatAmount(day.openingCashInHand ?? 0)} icon={Wallet} />
<StatCard label="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} />
<StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} />
<div
className={cn(
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
remaining > 0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border"
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Remaining cash in hand</p>
<PiggyBank className={cn("size-4", remaining > 0 ? "text-warning" : "text-muted-foreground")} aria-hidden />
</div>
<p className={cn("text-xl font-bold tracking-tight tabular-nums", remaining > 0 ? "text-warning" : "text-foreground")}>
{formatAmount(remaining)}
</p>
<p className="text-xs text-muted-foreground">{remaining > 0 ? "Not yet deposited" : "All cash banked"}</p>
</div>
<StatCard
label="Remaining cash in hand"
value={formatAmount(remaining)}
icon={PiggyBank}
hint={remaining > 0 ? "Not yet deposited" : "All cash banked"}
className={remaining > 0 ? "bg-warning/10 ring-warning/30" : undefined}
iconClassName={remaining > 0 ? "bg-warning/15 text-warning" : undefined}
valueClassName={remaining > 0 ? "text-warning" : undefined}
/>
</div>
<p className="text-xs text-muted-foreground">
Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go
@@ -149,6 +160,28 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
{deposits.length === 0 ? (
<p className="text-sm text-muted-foreground">No bank deposits for {formatReportDate(date)}.</p>
) : (
<>
<ul className="flex flex-col gap-2 md:hidden">
{depositPage.pageRows.map((d) => (
<li key={d.bankDepositId} className={mobileCard()}>
<div className="flex items-start justify-between gap-2">
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="font-medium text-primary hover:underline">{d.docNo}</Link>
<span className="font-mono font-semibold tabular-nums">{formatAmount(d.amount)}</span>
</div>
<p className="text-xs text-muted-foreground">
{[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · ")}
</p>
<p className="text-xs text-muted-foreground">
Slip {d.depositSlipNo ?? "—"} · {formatReportDate(d.depositDate)}
</p>
</li>
))}
<li className={cn(mobileCard(true), "flex-row items-center justify-between")}>
<span>Total deposited</span>
<span className="font-mono tabular-nums">{formatAmount(day.cashDeposited ?? 0)}</span>
</li>
</ul>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
@@ -160,7 +193,7 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
</TableRow>
</TableHeader>
<TableBody>
{deposits.map((d) => (
{depositPage.pageRows.map((d) => (
<TableRow key={d.bankDepositId}>
<TableCell>
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="text-primary hover:underline">{d.docNo}</Link>
@@ -184,6 +217,11 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
</TableRow>
</TableFooter>
</Table>
</div>
{depositPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={depositPage.pagination} onPageChange={depositPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={depositPage.setPageSize} />
)}
</>
)}
</div>
)
@@ -272,6 +310,10 @@ export default function DayEndReportPage() {
const sales = day?.data?.sales ?? []
const expenses = day?.data?.expenses ?? []
const deposits = day?.data?.deposits ?? []
// Paged in the browser (the report returns whole lists); a new date / range starts on page 1.
const salesPage = useClientPagination(sales, date)
const expensePage = useClientPagination(expenses, date)
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
return (
<div className="flex flex-col gap-6">
@@ -305,7 +347,7 @@ export default function DayEndReportPage() {
{!s && !day?.error && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-[84px] rounded-xl" />)}
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
</div>
)}
@@ -325,27 +367,15 @@ export default function DayEndReportPage() {
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
<div
className={cn(
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Income</p>
{s.income >= 0
? <TrendingUp className="size-4 text-success" aria-hidden />
: <TrendingDown className="size-4 text-destructive" aria-hidden />}
</div>
<p className={cn("text-xl font-bold tracking-tight tabular-nums", s.income >= 0 ? "text-success" : "text-destructive")}>
{formatSignedAmount(s.income)}
</p>
{s.income < 0 ? (
<p className="text-xs font-semibold text-destructive">Loss — expenses are more than sales</p>
) : (
<p className="text-xs text-muted-foreground">Sales − expenses</p>
)}
</div>
<StatCard
label="Income"
value={formatSignedAmount(s.income)}
icon={s.income >= 0 ? TrendingUp : TrendingDown}
hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"}
className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"}
iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}
valueClassName={s.income >= 0 ? "text-success" : "text-destructive"}
/>
</div>
<p className="text-sm text-muted-foreground">
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
@@ -361,7 +391,48 @@ export default function DayEndReportPage() {
{sales.length === 0 ? (
<p className="text-sm text-muted-foreground">No posted sales on {formatReportDate(date)}.</p>
) : (
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
<>
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{salesPage.pageRows.map((row) => (
<li key={`${row.docType}-${row.docId}`} className={mobileCard()}>
<div className="flex items-start justify-between gap-2">
<span className="min-w-0">
{row.docType === "Invoice" ? (
<Link href={`/dashboard/sales/invoices/${row.docId}`} className="font-medium text-primary hover:underline">{row.docNo}</Link>
) : (
<span className="font-medium">{row.docNo}</span>
)}
<span className="block truncate text-xs text-muted-foreground">
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · {row.docType} · {row.customerName}
</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-mono font-semibold tabular-nums">{formatAmount(row.total)}</span>
<span className="block text-xs text-muted-foreground">{row.paidBy}</span>
</span>
</div>
<div className="grid grid-cols-4 gap-2 border-t pt-2">
<KV label="Paid">{formatAmount(row.paid)}</KV>
<KV label="Due">{formatAmount(row.due, true)}</KV>
<KV label="Cost">{formatAmount(row.cost)}</KV>
<KV label="Profit" className={cn(row.grossProfit < 0 && "text-destructive")}>{formatAmount(row.grossProfit)}</KV>
</div>
</li>
))}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total</span>
<span className="font-mono tabular-nums">{formatAmount(s.totalSales)}</span>
</div>
<div className="grid grid-cols-4 gap-2 border-t pt-2">
<KV label="Paid">{formatAmount(s.paidAmount)}</KV>
<KV label="Due">{formatAmount(s.dueAmount)}</KV>
<KV label="Cost">{formatAmount(s.costOfGoodsSold)}</KV>
<KV label="Profit">{formatAmount(s.grossProfit)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
@@ -378,7 +449,7 @@ export default function DayEndReportPage() {
</TableRow>
</TableHeader>
<TableBody>
{sales.map((row) => (
{salesPage.pageRows.map((row) => (
<TableRow key={`${row.docType}-${row.docId}`}>
<TableCell className="tabular-nums">
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
@@ -415,6 +486,10 @@ export default function DayEndReportPage() {
</TableFooter>
</Table>
</div>
{salesPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={salesPage.pagination} onPageChange={salesPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={salesPage.setPageSize} />
)}
</>
)}
</div>
@@ -423,6 +498,29 @@ export default function DayEndReportPage() {
{expenses.length === 0 ? (
<p className="text-sm text-muted-foreground">No posted expenses on {formatReportDate(date)}.</p>
) : (
<>
<ul className="flex flex-col gap-2 md:hidden">
{expensePage.pageRows.map((e) => (
<li key={e.expenseId} className={mobileCard()}>
<div className="flex items-start justify-between gap-2">
<span className="min-w-0">
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="font-medium text-primary hover:underline">{e.docNo}</Link>
<span className="block truncate text-xs text-muted-foreground">{e.categoryName} · {e.titleName}</span>
{e.payee && <span className="block truncate text-xs text-muted-foreground">Payee: {e.payee}</span>}
</span>
<span className="shrink-0 text-right">
<span className="block font-mono font-semibold tabular-nums">{formatAmount(e.amount)}</span>
<span className="block text-xs text-muted-foreground">Paid {formatAmount(e.paidAmount)}</span>
</span>
</div>
</li>
))}
<li className={cn(mobileCard(true), "flex-row items-center justify-between")}>
<span>Total</span>
<span className="font-mono tabular-nums">{formatAmount(s.expenseTotal)}</span>
</li>
</ul>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
@@ -435,7 +533,7 @@ export default function DayEndReportPage() {
</TableRow>
</TableHeader>
<TableBody>
{expenses.map((e) => (
{expensePage.pageRows.map((e) => (
<TableRow key={e.expenseId}>
<TableCell>
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link>
@@ -456,6 +554,11 @@ export default function DayEndReportPage() {
</TableRow>
</TableFooter>
</Table>
</div>
{expensePage.pagination.totalItems > 10 && (
<PaginationFooter pagination={expensePage.pagination} onPageChange={expensePage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={expensePage.setPageSize} />
)}
</>
)}
</div>
@@ -471,16 +574,16 @@ export default function DayEndReportPage() {
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap">
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-from">From</Label>
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-44" />
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-full sm:w-44" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-to">To</Label>
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-44" />
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
</div>
<Button onClick={downloadRange} disabled={!range?.data || downloading !== null}>
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading !== null}>
<FileDown className="size-4" />
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
</Button>
@@ -493,7 +596,7 @@ export default function DayEndReportPage() {
{!rangeError && range?.data && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
@@ -510,9 +613,64 @@ export default function DayEndReportPage() {
/>
</div>
<DayEndIncomeChart days={range.data.days} />
{/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */}
<div className="mt-4 rounded-xl border border-border p-3 sm:p-4">
<DayEndIncomeChart days={range.data.days} />
</div>
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
{/* Phones: one card per day (newest first), tap to show that day above. */}
<ul className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto md:hidden">
{dayPage.pageRows.map((d) => {
const p = profitOrLoss(d.netProfit)
return (
<li key={d.date}>
<button
type="button"
onClick={() => d.date && setDate(d.date)}
className={cn(mobileCard(), "w-full text-left hover:bg-muted/40")}
>
<div className="flex items-start justify-between gap-2">
<span>
<span className="block font-medium">{formatReportDate(d.date)}</span>
<span className="block text-xs text-muted-foreground">{d.salesCount} sale{d.salesCount === 1 ? "" : "s"}</span>
</span>
<span className={cn("text-right", p.isLoss && "text-destructive")}>
<span className="block text-xs">{p.label}</span>
<span className="block font-mono font-semibold tabular-nums">{p.amount}</span>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(d.totalSales)}</KV>
<KV label="Expenses">{formatAmount(d.expenseTotal)}</KV>
<KV label="Income" className={cn(d.income < 0 && "text-destructive")}>{formatSignedAmount(d.income)}</KV>
<KV label="Cash">{formatAmount(d.paidByCash)}</KV>
<KV label="Card">{formatAmount(d.paidByCard)}</KV>
<KV label="Due">{formatAmount(d.dueAmount)}</KV>
<KV label="Deposited">{formatAmount(d.cashDeposited ?? 0, true)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(d.closingCashInHand ?? 0)}</KV>
</div>
</button>
</li>
)
})}
<li className={mobileCard(true)}>
<div className="flex items-center justify-between">
<span>Total · {range.data.totals.salesCount} sales</span>
<span className={cn("font-mono tabular-nums", range.data.totals.netProfit < 0 && "text-destructive")}>
{profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-t pt-2">
<KV label="Sales">{formatAmount(range.data.totals.totalSales)}</KV>
<KV label="Expenses">{formatAmount(range.data.totals.expenseTotal)}</KV>
<KV label="Income">{formatSignedAmount(range.data.totals.income)}</KV>
<KV label="Deposited">{formatAmount(range.data.totals.cashDeposited ?? 0)}</KV>
<KV label="Cash in hand" className="col-span-2">{formatAmount(range.data.totals.closingCashInHand ?? 0)}</KV>
</div>
</li>
</ul>
<div className="hidden max-h-[28rem] overflow-auto rounded-lg border border-border md:block">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
@@ -531,7 +689,7 @@ export default function DayEndReportPage() {
</TableRow>
</TableHeader>
<TableBody>
{[...range.data.days].reverse().map((d) => (
{dayPage.pageRows.map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
@@ -568,6 +726,9 @@ export default function DayEndReportPage() {
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
@@ -2,17 +2,17 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { salesDayEndApi } from "@/lib/api/sales-day-end"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { ManagedUser } from "@/types/users"
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceListTotals, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
@@ -21,16 +21,11 @@ import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/s
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
type SalesDateRange = "All" | "Today" | "7d" | "30d"
@@ -77,24 +72,14 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [
{ value: "Paid", label: "Fully paid" },
]
export default function SalesDayEndPage() {
export default function ApprovedInvoicesPage() {
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [businessDate, setBusinessDate] = useState(todayIso())
const [preview, setPreview] = useState<SalesDayEndPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [report, setReport] = useState<SalesDayEnd | null>(null)
const [closing, setClosing] = useState(false)
// Approved (Posted) sales invoices, shown as a table with its own filters (independent of the
// business date above): a cashier (null follows the cashier picked above, "All" shows every
// Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every
// cashier's sales) and a date range.
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null)
const effectiveSalesCashier = salesCashier ?? cashierUserId
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
const [salesCashier, setSalesCashier] = useState<number | "All">("All")
const effectiveSalesCashier = salesCashier
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("All")
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
const [salesSearchInput, setSalesSearchInput] = useState("")
@@ -135,30 +120,10 @@ export default function SalesDayEndPage() {
useEffect(() => {
usersApi
.list({ pageSize: 200 })
.then((res) => {
setCashiers(res.items)
setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null)
})
.then((res) => setCashiers(res.items))
.catch(() => {})
}, [])
function loadPreview() {
if (!cashierUserId) return
setReport(null)
setPreviewError(null)
setPreviewLoading(true)
salesDayEndApi
.preview(cashierUserId, businessDate)
.then(setPreview)
.catch((err) => setPreviewError(errorMessage(err)))
.finally(() => setPreviewLoading(false))
}
useEffect(() => {
loadPreview()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cashierUserId, businessDate])
// Debounced search; a new term starts again from page 1.
useEffect(() => {
const timeout = setTimeout(() => {
@@ -170,7 +135,6 @@ export default function SalesDayEndPage() {
}, [salesSearchInput])
useEffect(() => {
if (effectiveSalesCashier === null) return
salesApi
.listInvoices({
page: salesPage,
@@ -186,6 +150,41 @@ export default function SalesDayEndPage() {
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
// Stat cards: the same filters as the table, totalled over every match (not one page). Keyed
// like the table so a change of dropdown — or a payment — refreshes them.
const totalsKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesReload].join("|")
const [totalsResult, setTotalsResult] = useState<{ key: string; data: SalesInvoiceListTotals | null } | null>(null)
const totals = totalsResult?.key === totalsKey ? totalsResult.data : undefined
// If the totals endpoint can't be reached (e.g. an older backend), the cards still show — summed
// from the loaded page and labelled as such — rather than disappearing.
const pageTotals: SalesInvoiceListTotals | null = sales
? {
count: salesPagination?.totalItems ?? sales.length,
netTotal: sales.reduce((sum, r) => sum + r.totals.grandTotal, 0),
paidTotal: sales.reduce((sum, r) => sum + r.totals.paidAmount, 0),
balanceTotal: sales.reduce((sum, r) => sum + Math.max(0, r.totals.balanceAmount), 0),
unpaidCount: sales.filter((r) => r.totals.balanceAmount > 0).length,
overdueCount: sales.filter(isOverdue).length,
}
: null
const pageOnly = totals === null
const cardTotals = pageOnly ? (pageTotals ?? undefined) : totals
useEffect(() => {
salesApi
.invoiceTotals({
status: "Posted",
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom,
to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
q: salesQuery || undefined,
})
.then((data) => setTotalsResult({ key: totalsKey, data }))
// Cards are a summary — a failure blanks them (the table reports its own errors).
.catch(() => setTotalsResult({ key: totalsKey, data: null }))
}, [totalsKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery])
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
function reloadSales() {
setSelected(new Map())
@@ -198,12 +197,6 @@ export default function SalesDayEndPage() {
setSelected(new Map())
}
function changeCashier(userId: number | null) {
setCashierUserId(userId)
setSalesCashier(null)
resetSalesView()
}
function changeSalesCashier(value: number | "All") {
setSalesCashier(value)
resetSalesView()
@@ -224,23 +217,6 @@ export default function SalesDayEndPage() {
resetSalesView()
}
async function handleClose() {
if (!cashierUserId) return
setClosing(true)
try {
const result = await salesDayEndApi.close({ cashierUserId, businessDate })
setReport(result)
toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`)
loadPreview()
} catch (err) {
toast.error("Could not close the day", errorMessage(err))
} finally {
setClosing(false)
}
}
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
@@ -262,220 +238,28 @@ export default function SalesDayEndPage() {
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<div className="flex flex-col gap-4 sm:gap-6">
<div className="flex items-start gap-3 sm:items-center">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }), "shrink-0")}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
<p className="text-base text-muted-foreground">
Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Approved Invoices</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Approved sales invoices — view them and take payments, one at a time or several of one customer together.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Cashier</Label>
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
<SelectContent>
{cashiers.map((c) => (
<SelectItem key={c.userId} value={c.userId} className="text-base">
{c.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Business date</Label>
<Input
type="date"
value={businessDate}
max={todayIso()}
onChange={(e) => setBusinessDate(e.target.value)}
className="h-12 text-base"
/>
</div>
</div>
{previewLoading && <Skeleton className="h-40 w-full" />}
{previewError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{previewError}</div>
)}
{!previewLoading && preview && !report && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
{preview.alreadyClosed ? (
<div className="flex items-start gap-3 rounded-lg border border-success/30 bg-success/5 p-4 text-base text-success">
<CheckCircle2 className="size-5 shrink-0" />
<div>
<p className="font-semibold">{cashierName ?? `Cashier ${cashierUserId}`} already closed {businessDate}.</p>
<p className="text-sm text-success/80">{preview.slipCount} slip(s), {preview.bundleCount} bundle(s), {preview.grandTotal.toFixed(2)} total.</p>
</div>
</div>
) : preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 ? (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p className="font-semibold">Still Draft — post or cancel these before closing.</p>
<ul className="list-inside list-disc text-sm">
{preview.draftSlipsBlockingClose.map((s) => (
<li key={`slip-${s.salesSlipId}`}>
{s.slipNo} — {s.grandTotal.toFixed(2)}
</li>
))}
{preview.draftBundleSalesBlockingClose.map((b) => (
<li key={`bundle-${b.bundleSaleId}`}>
{b.bundleNo} — {b.grandTotal.toFixed(2)}
</li>
))}
</ul>
</div>
</div>
) : (
<p className="text-base text-muted-foreground">
{preview.slipCount === 0 && preview.bundleCount === 0
? preview.invoices.invoiceCount > 0
? `No sales slips or bundle sales for this cashier on this date — only ${preview.invoices.invoiceCount} sales invoice(s), which don't close through Day End (see below).`
: "No sales slips or bundle sales for this cashier on this date."
: `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`}
</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Subtotal" value={preview.subtotal} />
<Stat label="Discount" value={preview.discountTotal} />
<Stat label="Tax" value={preview.taxTotal} />
<Stat label="Grand total" value={preview.grandTotal} emphasize />
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Paid (cash)" value={preview.paidTotal} />
<Stat label="On account (balance)" value={preview.balanceTotal} />
</div>
<InvoiceSummaryPanel invoices={preview.invoices} />
{!preview.alreadyClosed && (
<div className="flex justify-end">
<Button
size="lg"
onClick={handleClose}
disabled={closing || preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 || !cashierUserId}
>
<Lock className="size-5" />
{closing ? "Closing…" : "Close day"}
</Button>
</div>
)}
{preview.alreadyClosed && (
<div className="flex justify-end">
<Button size="lg" variant="outline" onClick={handleClose} disabled={closing}>
<ReceiptText className="size-5" />
{closing ? "Loading…" : "View full report"}
</Button>
</div>
)}
</div>
)}
{report && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-1 border-b border-border pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">{report.docNo}</h2>
<p className="text-sm text-muted-foreground">
{cashierName ?? `Cashier ${report.cashierUserId}`} — {report.businessDate} — closed {new Date(report.closedAt).toLocaleString()}
</p>
</div>
{report.glJournalNo && (
<div className="text-sm text-muted-foreground">
GL journal <span className="font-medium text-foreground">{report.glJournalNo}</span> posted {report.glPostedAt ? new Date(report.glPostedAt).toLocaleString() : ""}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-6">
<Stat label="Slips" value={report.slipCount} isCount />
<Stat label="Bundles" value={report.bundleCount} isCount />
<Stat label="Subtotal" value={report.subtotal} />
<Stat label="Discount" value={report.discountTotal} />
<Stat label="Tax" value={report.taxTotal} />
<Stat label="Grand total" value={report.grandTotal} emphasize />
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Paid (cash)" value={report.paidTotal} />
<Stat label="On account (balance)" value={report.balanceTotal} />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
{report.itemBreakdown.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No lines.</p>
) : (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{report.itemBreakdown.map((line) => (
<div key={line.itemId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
<span className="min-w-0 truncate">{line.sku} — {line.name}</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{line.qty} × {line.revenue.toFixed(2)}
</span>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.itemBreakdown.map((line) => (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
{report.slipNumbers.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Slips included</h3>
<p className="text-sm text-muted-foreground">{report.slipNumbers.join(", ")}</p>
</div>
)}
{report.bundleNumbers.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Bundle sales included</h3>
<p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p>
</div>
)}
<InvoiceSummaryPanel invoices={report.invoices} />
</div>
)}
<InvoiceTotalsCards
totals={cardTotals}
pageOnly={pageOnly}
scope={[
salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
salesDateRanges.find((r) => r.value === salesDateRange)?.label ?? "",
paymentFilters.find((p) => p.value === salesPayment)?.label ?? "",
].filter(Boolean).join(" · ") + (salesQuery ? ` · “${salesQuery}”` : "")}
/>
<SalesTable
rows={sales}
@@ -634,24 +418,28 @@ function SalesTable({
const visible = rows ?? []
const filtered = paymentFilter !== "All" || search.trim().length > 0
const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0)
const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0)
const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0)
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className={cn(PANEL, "flex flex-col gap-4")}>
<div>
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{/* Phones: search on top, cashier full-width, then date + payment side by side. */}
<div className="grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-4">
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search invoice or customer"
className="col-span-2 h-11 text-base sm:col-span-1 sm:h-10 sm:text-sm lg:order-last"
/>
<div className="col-span-2 sm:col-span-1">
<Select<number | "All" | null>
value={cashierFilter}
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier">
<SelectTrigger className="h-11! w-full text-sm sm:h-10!" aria-label="Cashier">
<SelectValue placeholder="Cashier" />
</SelectTrigger>
<SelectContent>
@@ -661,12 +449,13 @@ function SalesTable({
))}
</SelectContent>
</Select>
</div>
<Select<SalesDateRange>
value={dateRange}
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
onValueChange={(v) => onDateRangeChange(v ?? "All")}
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range">
<SelectTrigger className="h-11! w-full min-w-0 text-sm sm:h-10!" aria-label="Date range">
<CalendarDays className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
@@ -681,7 +470,7 @@ function SalesTable({
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status">
<SelectTrigger className="h-11! w-full min-w-0 text-sm sm:h-10!" aria-label="Payment status">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -690,12 +479,6 @@ function SalesTable({
))}
</SelectContent>
</Select>
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search invoice or customer"
className="h-10 text-sm"
/>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
@@ -709,17 +492,18 @@ function SalesTable({
)}
{selected.size > 0 && (
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
// Sticky on phones so "Pay selected" stays reachable while ticking rows further down.
<div className="sticky bottom-0 z-10 flex flex-col gap-2 rounded-lg border bg-card px-4 py-3 shadow-lg ring-1 ring-primary/20 sm:static sm:flex-row sm:items-center sm:justify-between sm:bg-primary/5 sm:shadow-none sm:ring-0">
<p className="text-sm">
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
<div className="grid grid-cols-2 gap-2 sm:flex">
<Button type="button" variant="outline" size="sm" className="h-10 sm:h-8" onClick={onClearSelection}>
Clear
</Button>
<Button type="button" size="sm" onClick={onPaySelected}>
<Button type="button" size="sm" className="h-10 sm:h-8" onClick={onPaySelected}>
<CircleDollarSign className="size-4" />
Pay selected
</Button>
@@ -729,48 +513,66 @@ function SalesTable({
{!error && rows !== null && visible.length > 0 && (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{/* Phones and tablets (below lg): a card per sale — two per row from sm. Details on top, amounts + actions on a row of their own so
nothing is squeezed. Tapping the details opens the invoice. */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:hidden">
{visible.map((row) => (
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
{renderSelectBox(row)}
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<span className="min-w-0">
<span className="block truncate font-medium">{row.invoiceNo}</span>
<span className="block truncate text-muted-foreground">
{row.customerSnapshotName}
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
</span>
<span className="block text-xs text-muted-foreground">
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
<div
key={row.salesInvoiceId}
className={cn(
"flex flex-col gap-3 rounded-lg border p-3 text-sm",
selected.has(row.salesInvoiceId) && "border-primary/50 bg-primary/5"
)}
>
<div className="flex items-start gap-3">
<div className="pt-0.5">{renderSelectBox(row)}</div>
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-start justify-between gap-2 text-left">
<span className="min-w-0">
<span className="block truncate font-semibold">{row.invoiceNo}</span>
<span className="block truncate text-muted-foreground">
{row.customerSnapshotName}
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
</span>
<span className="block text-xs text-muted-foreground">
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</span>
</span>
</span>
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
</span>
</button>
{renderActions(row)}
</button>
</div>
<div className="flex items-end justify-between gap-2 border-t pt-2">
<div className="grid grid-cols-3 gap-x-3 text-xs">
<span className="text-muted-foreground">Net</span>
<span className="text-muted-foreground">Paid</span>
<span className="text-muted-foreground">Due</span>
<span className="font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
<span className="font-mono tabular-nums">{row.totals.paidAmount.toFixed(2)}</span>
<span className={cn("font-mono tabular-nums", row.totals.balanceAmount > 0 && "font-semibold text-destructive")}>
{row.totals.balanceAmount.toFixed(2)}
</span>
</div>
{renderActions(row)}
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
{/* Desktop (lg+): table. Date and Paid only appear from xl, where there is room for every column. */}
<div className="hidden overflow-x-auto rounded-xl border lg:block">
<Table className="text-sm xl:text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="hidden h-12 px-3 text-sm xl:table-cell">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead>
<TableHead className="hidden h-12 px-3 text-right text-sm xl:table-cell">Paid</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
@@ -790,16 +592,16 @@ function SalesTable({
{renderSelectBox(row)}
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="max-w-48 truncate px-3 py-3.5" title={row.customerSnapshotName}>{row.customerSnapshotName}</TableCell>
{showCashier && (
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
)}
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="hidden px-3 py-3.5 xl:table-cell">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
<TableCell className="hidden px-3 py-3.5 text-right tabular-nums xl:table-cell">{row.totals.paidAmount.toFixed(2)}</TableCell>
<TableCell
className={cn(
"px-3 py-3.5 text-right tabular-nums",
@@ -833,52 +635,61 @@ function SalesTable({
onPageSizeChange={onPageSizeChange}
/>
)}
{/* Invoices counts every match; the amounts are for the rows on this page. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
<Stat label="Net (this page)" value={netTotal} emphasize />
<Stat label="Paid (this page)" value={paidTotal} />
<Stat label="Remaining (this page)" value={balanceTotal} />
</div>
</>
)}
</div>
)
}
// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have
// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros
// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales.
function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) {
if (invoices.invoiceCount === 0) return null
return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/20 p-4">
<div>
<h3 className="text-sm font-semibold text-foreground">Sales invoices (not part of this close)</h3>
<p className="text-xs text-muted-foreground">
Invoices post their own ledger entry when confirmed — shown here for visibility only.
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Stat label="Invoices" value={invoices.invoiceCount} isCount />
<Stat label="Subtotal" value={invoices.invoiceSubtotal} />
<Stat label="Discount" value={invoices.invoiceDiscountTotal} />
<Stat label="Tax" value={invoices.invoiceTaxTotal} />
<Stat label="Grand total" value={invoices.invoiceGrandTotal} emphasize />
</div>
<p className="text-sm text-muted-foreground">{invoices.invoiceNumbers.join(", ")}</p>
</div>
)
}
/** Shared card shell — tighter padding on phones, roomier from `sm` up. */
const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)] sm:p-5"
/**
* Top-of-page totals for whatever the dropdowns/search currently select — across every matching
* invoice, not just the visible page (`pageOnly` marks the fallback when that isn't available).
* `undefined` = loading. Same StatCard as the other screens' KPI rows.
*/
function InvoiceTotalsCards({
totals, scope, pageOnly,
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) {
if (totals === undefined) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
</div>
)
}
if (totals === null) return null
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) {
return (
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("font-mono tabular-nums", emphasize ? "text-lg font-bold text-foreground" : "text-base font-semibold text-foreground")}>
{isCount ? value : value.toFixed(2)}
</span>
<div className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground">
Totals for: {scope}
{pageOnly && <span className="text-warning"> — amounts cover this page only</span>}
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatCard label="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} />
<StatCard label="Net total" value={formatAmount(totals.netTotal)} icon={Wallet} hint="Approved value" />
<StatCard label="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" />
<StatCard
label="Balance due"
value={formatAmount(totals.balanceTotal)}
icon={Hourglass}
hint="Still to collect"
className={totals.balanceTotal > 0 ? "bg-warning/10 ring-warning/30" : undefined}
iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined}
/>
<StatCard
label="Overdue"
value={totals.overdueCount}
icon={AlertTriangle}
hint="Past due date, not fully paid"
className={cn("col-span-2 sm:col-span-1", totals.overdueCount > 0 && "bg-destructive/10 ring-destructive/30")}
iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined}
/>
</div>
</div>
)
}
@@ -15,6 +15,7 @@ import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { salesConfigApi } from "@/lib/api/sales-config"
import { errorMessage } from "@/lib/error-map"
import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils"
import { Customer } from "@/types/customers"
@@ -638,8 +639,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
{(line.serials ?? []).length > 0 && (
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")}
// From – To runs, not one entry per unit (full list on hover).
<div
className="mt-0.5 font-mono text-xs break-all text-muted-foreground"
title={(line.serials ?? []).map((s) => s.serialNo).join(", ")}
>
S/N {formatSerialRanges(toSerialRanges((line.serials ?? []).map((s) => s.serialNo)), 3)}
</div>
)}
</td>
@@ -154,7 +154,8 @@ export default function SalesInvoicesPage() {
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
// Includes the cash customer so its (walk-in) invoices can be filtered too.
customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
@@ -24,8 +24,8 @@ const sections = [
icon: PackageX,
},
{
title: "Day End",
description: "Close a cashier's day and post it to the ledger.",
title: "Approved Invoices",
description: "Approved sales invoices — view them and take payments.",
href: "/dashboard/sales/day-end",
icon: Lock,
},
@@ -33,7 +33,7 @@ const STATUS_TONE: Partial<Record<SerialStatus, string>> = {
/** Friendly names for the document-type prefixes a serial movement references. */
const DOC_LABEL: Record<string, string> = {
GRN: "Goods receipt",
GRN: "Goods received",
SI: "Sales invoice",
SSL: "Sales slip",
BND: "Bundle sale",
+7 -19
View File
@@ -14,6 +14,7 @@ import { Vendor } from "@/types/master-data"
import { GrnSummary } from "@/types/grn"
import { Button, buttonVariants } from "@/components/ui/button"
import { StatCard } from "@/components/ui/stat-card"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -27,21 +28,6 @@ import BarChart from "@/components/ui/bar-chart"
const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function VendorDetailPage() {
const params = useParams<{ id: string }>()
const vendorId = Number(params.id)
@@ -293,11 +279,13 @@ export default function VendorDetailPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip
{/* Fixed-share columns so the three cards stay compact on wide screens instead of each taking a third. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<StatCard icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatCard icon={PackageCheck} label="Goods received (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatCard
icon={Truck}
className="col-span-2 sm:col-span-1"
label="Last delivery"
value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"}
/>
@@ -33,6 +33,7 @@ import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { limitPhoneInput, phoneError } from "@/lib/validations/phone"
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH"
* so an empty/punctuation-only name still yields a usable base. */
@@ -153,13 +154,18 @@ export default function WarehousesPage() {
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Warehouse name is required"
const phoneProblem = phoneError(phone)
if (phoneProblem) nextErrors.phone = phoneProblem
if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create warehouse", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId })
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone.trim() || null, branchId })
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
closeCreate()
load()
@@ -187,13 +193,18 @@ export default function WarehousesPage() {
const nextErrors: Record<string, string> = {}
if (!editCode.trim()) nextErrors.code = "Code is required"
if (!editName.trim()) nextErrors.name = "Warehouse name is required"
const phoneProblem = phoneError(editPhone)
if (phoneProblem) nextErrors.phone = phoneProblem
if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required"
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not update warehouse", Object.values(nextErrors)[0])
return
}
setEditSubmitting(true)
try {
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone || null, branchId: editBranchId })
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone.trim() || null, branchId: editBranchId })
toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`)
setEditTarget(null)
load()
@@ -262,9 +273,20 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-address">Address (optional)</FieldLabel>
<Input id="wh-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Street, city…" />
</Field>
<Field>
<Field data-invalid={!!errors.phone}>
<FieldLabel htmlFor="wh-phone">Phone (optional)</FieldLabel>
<Input id="wh-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+94 …" />
<Input
id="wh-phone"
type="tel"
inputMode="tel"
maxLength={20}
value={phone}
onChange={(e) => { setPhone(limitPhoneInput(e.target.value)); setErrors(dropError("phone")) }}
onBlur={() => { const p = phoneError(phone); if (p) setErrors((prev) => ({ ...prev, phone: p })) }}
placeholder="0771234567 or +94771234567"
aria-invalid={!!errors.phone}
/>
<FieldError errors={[errors.phone ? { message: errors.phone } : undefined]} />
</Field>
{warehouseBranchingEnabled && (
<Field data-invalid={!!errors.branchId}>
@@ -556,9 +578,20 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-edit-address">Address (optional)</FieldLabel>
<Input id="wh-edit-address" value={editAddress} onChange={(e) => setEditAddress(e.target.value)} placeholder="Street, city…" />
</Field>
<Field>
<Field data-invalid={!!editErrors.phone}>
<FieldLabel htmlFor="wh-edit-phone">Phone (optional)</FieldLabel>
<Input id="wh-edit-phone" value={editPhone} onChange={(e) => setEditPhone(e.target.value)} placeholder="+94 …" />
<Input
id="wh-edit-phone"
type="tel"
inputMode="tel"
maxLength={20}
value={editPhone}
onChange={(e) => { setEditPhone(limitPhoneInput(e.target.value)); setEditErrors(dropError("phone")) }}
onBlur={() => { const p = phoneError(editPhone); if (p) setEditErrors((prev) => ({ ...prev, phone: p })) }}
placeholder="0771234567 or +94771234567"
aria-invalid={!!editErrors.phone}
/>
<FieldError errors={[editErrors.phone ? { message: editErrors.phone } : undefined]} />
</Field>
{warehouseBranchingEnabled && (
<Field data-invalid={!!editErrors.branchId}>