ui fixers in 10/16
This commit is contained in:
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user