bug fixers 10/20

This commit is contained in:
2026-10-20 15:33:19 +05:30
parent 0e423b806c
commit 47479fb4f2
43 changed files with 13191 additions and 811 deletions
@@ -371,10 +371,21 @@ export default function ExpensesPage() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{summary ? (
<>
<StatCard label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt} />
<StatCard label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet} />
{/* Cards double as filters (click again to clear). "Outstanding (all time)" ignores the
period, so it has no matching list filter and stays plain. */}
<StatCard
label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt}
onClick={() => setStatus((s) => (s === "Posted" ? "All" : "Posted"))} active={status === "Posted"}
/>
<StatCard
label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet}
onClick={() => setPaymentFilter((p) => (p === "Unpaid" ? "All" : "Unpaid"))} active={paymentFilter === "Unpaid"}
/>
<StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} />
<StatCard label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle} />
<StatCard
label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle}
onClick={() => setPaymentFilter((p) => (p === "Overdue" ? "All" : "Overdue"))} active={paymentFilter === "Overdue"}
/>
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
@@ -97,14 +97,23 @@ export default function HelpPage() {
}
}, [tickets])
// Set by clicking a stat card (click again to clear).
const [statusFilter, setStatusFilter] = useState<"All" | "Open" | "InProgress" | "Resolved">("All")
function toggleStatus(next: "Open" | "InProgress" | "Resolved") {
setStatusFilter((current) => (current === next ? "All" : next))
setPage(1)
}
const filtered = useMemo(() => {
if (!tickets) return []
const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter(
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
(t) =>
(statusFilter === "All" || t.status === statusFilter) &&
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
)
}, [tickets, debouncedSearch])
}, [tickets, debouncedSearch, statusFilter])
const totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
@@ -210,10 +219,10 @@ export default function HelpPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? (
<>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
<StatCard label="Open" value={stats.open} icon={Inbox} />
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} onClick={() => { setStatusFilter("All"); setPage(1) }} active={statusFilter === "All"} />
<StatCard label="Open" value={stats.open} icon={Inbox} onClick={() => toggleStatus("Open")} active={statusFilter === "Open"} />
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} onClick={() => toggleStatus("InProgress")} active={statusFilter === "InProgress"} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} onClick={() => toggleStatus("Resolved")} active={statusFilter === "Resolved"} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -170,10 +170,12 @@ export default function EmployeesPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? (
<>
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
<StatCard label="Active" value={stats.active} icon={UserCheck} />
{/* Cards double as the status filter (click again to clear). "Inactive" spans several
statuses (suspended, resigned, retired…), so it has no single filter and stays plain. */}
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} onClick={() => setStatusFilter(undefined)} active={statusFilter === undefined} />
<StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))} active={statusFilter === "Active"} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))} active={statusFilter === "Terminated"} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -238,7 +238,14 @@ export default function LeaveRequestsPage() {
</DialogContent>
</Dialog>
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
{/* "Pending" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
<KpiRow
tiles={stats ? [{
label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock,
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
active: statusFilter === "Submitted",
}] : null}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="relative w-full sm:max-w-xs">
@@ -113,28 +113,31 @@ export default function PurchaseOrderApprovalsPage() {
>
<Eye className="size-4" />
</Link>
{/* Icon-only to keep the column narrow; the label lives in aria-label/title. */}
<Button
type="button"
size="sm"
variant="success"
size="icon-sm"
variant="ghost"
disabled={!canApprove || approvingId === po.poId}
onClick={() => handleApprove(po, reload)}
aria-label={`Approve ${po.docNo}`}
title={approvingId === po.poId ? "Approving…" : "Approve"}
>
<Check className="size-4" />
{approvingId === po.poId ? "Approving…" : "Approve"}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
size="icon-sm"
variant="ghost"
disabled={!canApprove}
onClick={() => {
setRejectReason("")
setRejectTarget({ po, reload })
}}
aria-label={`Reject ${po.docNo}`}
title="Reject"
>
<X className="size-4" />
Reject
</Button>
</>
)}
@@ -19,31 +19,15 @@ import { validatePoLine } from "@/lib/validations/procurement"
import { formatAmount } from "@/lib/format"
import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -147,16 +131,7 @@ function NewPurchaseOrderContent() {
const [submitting, setSubmitting] = useState(false)
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("")
const [vCurrency, setVCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig()
const [vOptional, setVOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false)
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
function loadItems() {
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
@@ -187,46 +162,6 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function resetVendorForm() {
setVName("")
setVTerms("")
setVTaxReg("")
setVCurrency("LKR")
setVOptional(EMPTY_VENDOR_OPTIONAL)
setVErrors({})
}
async function handleCreateVendor() {
const nextErrors: Record<string, string> = {}
if (!vName.trim()) nextErrors.name = "Vendor name is required"
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
Object.assign(nextErrors, validateVendorOptional(procConfig, vOptional))
setVErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setVSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedVendorCode,
name: vName,
...vendorTermsTaxPayload(procConfig, vTerms, vTaxReg),
currency: vCurrency,
...vendorOptionalPayload(procConfig, vOptional),
})
await loadVendors()
setVendorId(result.data.vendorId)
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setVendorDialogOpen(false)
resetVendorForm()
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setVSubmitting(false)
}
}
useEffect(() => {
if (requisitionId) {
requisitionsApi
@@ -446,62 +381,19 @@ function NewPurchaseOrderContent() {
</SelectContent>
</Select>
<Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
<DialogTrigger
render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
>
<Plus className="size-5" />
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!vErrors.name}>
<FieldLabel htmlFor="po-v-name">Name</FieldLabel>
<Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
<FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={vOptional}
onChange={(patch) => setVOptional((prev) => ({ ...prev, ...patch }))}
idPrefix="po-v"
errors={vErrors}
/>
<Field data-invalid={!!vErrors.currency}>
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : undefined]} />
</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={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
{vSubmitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" onClick={() => setVendorDialogOpen(true)}>
<Plus className="size-5" />
</Button>
<CreateVendorDialog
open={vendorDialogOpen}
onOpenChange={setVendorDialogOpen}
description="Create a supplier record without leaving this PO. Its code is generated from the name."
onCreated={(vendor) => {
// Active vendors only in this picker — a new one is Active, so add it and select it.
setVendors((prev) => [...(prev ?? []), vendor])
setVendorId(vendor.vendorId)
}}
/>
</div>
</div>
{branchingEnabled && isAdmin && (
@@ -7,7 +7,7 @@ import { Clock, Download, Eye, Pencil, Plus, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { errorMessage } from "@/lib/error-map"
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
import { PurchaseOrderSummary } from "@/types/procurement"
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
@@ -23,6 +23,8 @@ export default function PurchaseOrdersListPage() {
const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null)
const [pdfId, setPdfId] = useState<number | null>(null)
// Shared with the list's Status filter, so the "Pending Approval" tile and the dropdown stay in sync.
const [status, setStatus] = useState<PurchaseOrderStatus | "All">("All")
async function handleDelete(po: PurchaseOrderSummary, reload: () => void) {
setDeletingId(po.poId)
@@ -83,9 +85,16 @@ export default function PurchaseOrdersListPage() {
</div>
</div>
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
<KpiRow
tiles={stats ? [{
label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock,
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")), active: status === "PendingApproval",
}] : null}
/>
<PurchaseOrderListView
status={status}
onStatusChange={setStatus}
renderActions={(po, { reload }) => {
const editable = isPoEditable(po.status)
return (
@@ -78,7 +78,13 @@ export default function RequisitionsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
{/* "Open" = Submitted (DashboardService) — the tile toggles the Status filter to Submitted. */}
<KpiRow
tiles={stats ? [{
label: "Open Requisitions", value: stats.openRequisitions, icon: Send,
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")), active: status === "Submitted",
}] : null}
/>
<div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
@@ -25,6 +25,8 @@ export default function RfqsListPage() {
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null)
// Set by the "Open RFQs" tile (click again to clear).
const [openOnly, setOpenOnly] = useState(false)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
@@ -46,7 +48,8 @@ export default function RfqsListPage() {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
const visibleRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs
const branchRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs
const visibleRfqs = openOnly && branchRfqs ? branchRfqs.filter((r) => r.status === "Open") : branchRfqs
return (
<div className="flex flex-col gap-6">
@@ -61,7 +64,7 @@ export default function RfqsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText }] : null} />
<KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText, onClick: () => setOpenOnly((v) => !v), active: openOnly }] : null} />
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
@@ -3,11 +3,12 @@
import { useEffect, useMemo, useState } from "react"
import { useParams, usePathname, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Plus, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { uomsApi } from "@/lib/api/uoms"
@@ -19,7 +20,7 @@ import { WastageRecord, wastageApi } from "@/lib/api/wastage"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { Brand, Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, SubCategory, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { LedgerEntry } from "@/types/stock"
import { ItemPriceHistory } from "@/types/approvals"
@@ -43,6 +44,8 @@ import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
import { WarrantyFields } from "@/components/products/WarrantyFields"
import { AddUomDialog } from "@/components/products/AddUomDialog"
import { AddBrandDialog } from "@/components/products/AddBrandDialog"
/**
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
@@ -102,6 +105,11 @@ function ItemDetailPage() {
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
// Edit screen pickers — shown only while Product configuration has subcategories / brands on.
const [brands, setBrands] = useState<Brand[]>([])
const [subCategoryList, setSubCategoryList] = useState<{ categoryId: number | null; items: SubCategory[] }>({ categoryId: null, items: [] })
const [addUomOpen, setAddUomOpen] = useState(false)
const [addBrandOpen, setAddBrandOpen] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
// Profile activity: full recent movement history for this item, across every warehouse.
@@ -118,14 +126,11 @@ function ItemDetailPage() {
// No longer editable here — carried through unchanged so a save doesn't silently clear it.
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
// Not editable here — they are chosen on the create screen's builder.
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
// Default vendor, tax class, and tracking mode are no longer editable on this page —
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
// Default vendor isn't editable on this page — carried through unchanged so a save doesn't clear it.
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
// Warranty: edited in the Warranty section below (options follow Product configuration).
@@ -211,6 +216,7 @@ function ItemDetailPage() {
})
.catch(() => {})
productConfig().then(setConfig).catch(() => {})
brandsApi.list({ pageSize: 200, status: "Active" }).then((res) => setBrands(res.items)).catch(() => {})
warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {})
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
@@ -218,6 +224,17 @@ function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
// Subcategories are fetched per category, like the create screen.
// Tagged with the category they belong to, so a stale list is never shown for another one.
useEffect(() => {
if (categoryId === null || !config?.subcategoriesEnabled) return
categoriesApi
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
.then((res) => setSubCategoryList({ categoryId, items: res.items }))
.catch(() => setSubCategoryList({ categoryId, items: [] }))
}, [categoryId, config?.subcategoriesEnabled])
const subCategories = subCategoryList.categoryId === categoryId ? subCategoryList.items : []
// Recent activity + stock value: independent of the edit form above, loaded once the
// item id is known. A large page size is fine here — one item's lifetime history.
useEffect(() => {
@@ -313,7 +330,7 @@ function ItemDetailPage() {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
if (lowStockAt.trim()) {
if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
const point = Number(lowStockAt)
const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
@@ -326,20 +343,32 @@ function ItemDetailPage() {
// Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse
// rules set under Reorder settings survive an unrelated edit (name, price, …).
const saved = alertFromRules(item.reorder ?? [])
// Alert warehouse switched off: the picker is hidden and a changed alert watches every warehouse.
const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : saved.warehouseId
const alertChanged =
lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || warehouseId !== saved.warehouseId))
!!config?.lowStockAlertsEnabled &&
(lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || alertWarehouseId !== saved.warehouseId)))
// Follow Product configuration: a switched-off feature's value is not sent (the server
// answers CONFIG_DISABLED for a gated value), so saving still works after a switch is turned off.
const allowPrices = !!config?.sellingPricesEnabled
const allowWarranty = !!config?.warrantyPeriodsEnabled
setSaving(true)
try {
const result = await itemsApi.update(
item.itemId,
{
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number,
subCategoryId: config?.subcategoriesEnabled ? subCategoryId : null,
brandId: config?.brandsEnabled ? brandId : null,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
warranty: allowWarranty ? warranty : "NonWarranty",
warrantyPeriodMonths: allowWarranty && warranty === "Warranty" ? warrantyPeriodMonths : null,
taxRateId,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
sellingMinPrice: allowPrices && sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: allowPrices && sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: allowPrices && sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
},
@@ -347,7 +376,8 @@ function ItemDetailPage() {
)
let saved = result.data
if (alertChanged) {
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
const watchedId = config?.alertWarehouseEnabled ? warehouseId : null
const watched = watchedId !== null ? [watchedId] : warehouses.map((w) => w.warehouseId)
const settings = lowStockAt.trim()
? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
: []
@@ -428,114 +458,171 @@ function ItemDetailPage() {
}
if (editMode) {
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<Link
href={`/dashboard/products/${item.itemId}`}
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to item
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Edit {item.sku}</h1>
<p className="text-base text-muted-foreground">Update this item&apos;s basic info, pricing and classification.</p>
</div>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
</div>
/** Mobile-first section shell, same as the New Item screen. */
const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5"
// Tracking mode and base UOM give meaning to every stock row already recorded, so they are
// only changeable while the item has no movements yet (the server freezes the UOM too).
const hasHistory = ledger === null || ledger.length > 0
const trackingOptions: TrackingMode[] = [
"None",
...(config?.batchListsEnabled || trackingMode === "Batch" ? (["Batch"] as const) : []),
...(config?.serialNumbersEnabled || trackingMode === "Serial" ? (["Serial"] as const) : []),
]
return (
<div className="flex flex-col gap-5 sm:gap-6">
<Link
href={`/dashboard/products/${item.itemId}`}
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to item
</Link>
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Edit Item</h1>
<p className="text-sm text-muted-foreground sm:text-base">
{item.sku} — {item.name}
</p>
</div>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
</div>
)}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="flex flex-col gap-6 rounded-xl border bg-card p-5 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
</div>
)}
<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 sm:col-span-2">
<Label className="text-base" required>Item name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12! text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base" required>SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12! text-base" disabled={conflict} />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Barcode (optional)</Label>
<Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12! text-base" disabled={conflict} />
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base" required>Category</Label>
<Select<number | null>
value={categoryId}
onValueChange={(v) => {
setCategoryId(v)
// A subcategory belongs to one category — the old pick no longer fits.
if (v !== categoryId) setSubCategoryId(null)
}}
disabled={conflict}
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
{config?.subcategoriesEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Barcode (optional)</Label>
<Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
{config?.sellingPricesEnabled && (
<>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price (fixed value)</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
{pricesLocked && (
<p className="text-sm text-muted-foreground">
Selling prices need approval (Settings → Approvals).{" "}
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
Request a price change
</Link>
</p>
)}
</div>
</>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Tax rate</Label>
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null>
value={subCategoryId}
onValueChange={setSubCategoryId}
disabled={conflict || subCategories.length === 0}
items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
<SelectItem value={null} className="text-base">None</SelectItem>
{subCategories.map((s) => <SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">{s.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
</div>
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict} items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
<Label className="text-base">Brand (optional)</Label>
<div className="flex gap-2">
<Select<number | null>
value={brandId}
onValueChange={setBrandId}
disabled={conflict}
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>
<SelectItem value={null} className="text-base">None</SelectItem>
{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" disabled={conflict}>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Tax rate</Label>
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
<SelectContent>
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
</div>
</div>
</section>
<section className={sectionClass}>
<h2 className="text-lg font-semibold text-foreground">Units &amp; stock</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base" required>Base UOM</Label>
<div className="flex gap-2">
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict || hasHistory} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
<SelectTrigger className="h-12! w-full min-w-0 flex-1 text-base" aria-invalid={!!errors.baseUomId}><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>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
{!hasHistory && (
<Button type="button" variant="outline" className="h-12 shrink-0" onClick={() => setAddUomOpen(true)} aria-label="Add UOM" disabled={conflict}>
<Plus className="size-4" />
<span className="hidden sm:inline">New</span>
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
</Select>
</div>
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
<p className="text-sm text-muted-foreground">
{item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."}
</p>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
{hasHistory && <p className="text-sm text-muted-foreground">Locked — this item already has stock history.</p>}
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Content size</Label>
<div className="flex gap-2">
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
</Select>
</div>
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
<p className="text-sm text-muted-foreground">
{item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."}
</p>
</div>
{/* Off: hidden, and the item keeps the nature it already has (the server allows that). */}
{config?.stockNatureEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
@@ -547,56 +634,168 @@ function ItemDetailPage() {
</SelectContent>
</Select>
</div>
)}
{config?.trackingModeEnabled && (
<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)} placeholder="Alert when stock ≤" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
<Label className="text-base">Tracking mode</Label>
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict || hasHistory}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue /></SelectTrigger>
<SelectContent>
{trackingOptions.map((m) => <SelectItem key={m} value={m} className="text-base">{m}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{hasHistory
? "Locked — this item already has stock history, so its tracking can't change."
: config.serialNumbersEnabled
? "Choose Serial if each unit needs its own serial number on receipt."
: config.batchListsEnabled
? "Batch tracking is available; serial tracking is currently disabled in product settings."
: "Batch and serial tracking are currently disabled in product settings."}
</p>
</div>
)}
</div>
</section>
{config?.lowStockAlertsEnabled && (
<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>
<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)} placeholder="Alert at" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
<Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} />
</div>
<FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} />
<p className="text-sm text-muted-foreground">
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : 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.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${config.alertWarehouseEnabled && warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
{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.</p>
</div>
{config.alertWarehouseEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
{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>
{config?.warrantyPeriodsEnabled && (
<WarrantyFields
config={config}
customMonths={customWarrantyMonths}
warranty={warranty}
months={warrantyPeriodMonths}
onChange={(w, m) => {
setWarranty(w)
setWarrantyPeriodMonths(m)
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
}}
error={errors.warrantyPeriodMonths || undefined}
disabled={conflict}
/>
</section>
)}
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
{config?.sellingPricesEnabled && (
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Selling prices</h2>
<p className="text-sm text-muted-foreground">Optional — leave blank to sell at stock value.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Min price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Max price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Flat price</Label>
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="0.00" className="h-12! text-base" disabled={conflict || pricesLocked} />
</div>
</div>
{pricesLocked && (
<p className="text-sm text-muted-foreground">
Selling prices need approval (Settings → Approvals).{" "}
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
Request a price change
</Link>
</p>
)}
</section>
)}
{config?.warrantyPeriodsEnabled && (
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">Mark whether this item is sold under warranty.</p>
</div>
<WarrantyFields
hideLabel
config={config}
customMonths={customWarrantyMonths}
warranty={warranty}
months={warrantyPeriodMonths}
onChange={(w, m) => {
setWarranty(w)
setWarrantyPeriodMonths(m)
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
}}
error={errors.warrantyPeriodMonths || undefined}
disabled={conflict}
/>
</section>
)}
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" size="lg" onClick={() => router.push(`/dashboard/products/${item.itemId}`)} disabled={saving}>Cancel</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
)}
{/* Shown whenever the item has images so they can still be removed; uploads follow the Item images switch. */}
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
</h2>
{!imagesEnabled && <p className="text-sm text-muted-foreground">Adding images is switched off in Product configuration.</p>}
</div>
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
</section>
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{saveError}</div>
)}
{/* 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">
<Link href={`/dashboard/products/${item.itemId}`} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSave} disabled={saving || conflict} className="w-full sm:w-auto sm:min-w-40">
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
)
}
<AddUomDialog
open={addUomOpen}
onOpenChange={setAddUomOpen}
onCreated={(uom) => {
setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }])
setBaseUomId(uom.uomId)
}}
/>
<AddBrandDialog
open={addBrandOpen}
onOpenChange={setAddBrandOpen}
onCreated={(brand) => {
setBrands((prev) => [...prev, brand])
setBrandId(brand.brandId)
}}
/>
</div>
)
}
return (
<div className="flex flex-col gap-6">
@@ -672,15 +871,17 @@ function ItemDetailPage() {
{/* Per-warehouse low-stock levels. Re-keyed on the saved rules so it picks up a change
made through the Edit dialog's simple alert fields. */}
<ItemReorderPanel
key={JSON.stringify(item.reorder ?? [])}
itemId={item.itemId}
stockNature={item.stockNature}
settings={item.reorder ?? []}
warehouses={warehouses}
uomName={uomName(item.baseUomId)}
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
/>
{config?.lowStockAlertsEnabled && (
<ItemReorderPanel
key={JSON.stringify(item.reorder ?? [])}
itemId={item.itemId}
stockNature={item.stockNature}
settings={item.reorder ?? []}
warehouses={warehouses}
uomName={uomName(item.baseUomId)}
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
/>
)}
{/* Every applied selling-price change and who approved it (docs/DUAL_APPROVAL_PLAN.md §6). */}
<div className="flex flex-col gap-3 rounded-xl border p-5">
@@ -254,6 +254,19 @@ export default function NewItemPage() {
if (!config.batchListsEnabled && trackingMode === "Batch") {
setTrackingMode("None")
}
if (!config.trackingModeEnabled && trackingMode !== "None") {
setTrackingMode("None")
}
if (!config.stockNatureEnabled && stockNature !== "Stocked") {
setStockNature("Stocked")
}
if (!config.lowStockAlertsEnabled && (lowStockAt || reorderQty)) {
setLowStockAt("")
setReorderQty("")
}
if (!config.alertWarehouseEnabled && warehouseId !== null) {
setWarehouseId(null)
}
if (!config.warrantyPeriodsEnabled && warranty === "Warranty") {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
@@ -267,6 +280,10 @@ export default function NewItemPage() {
}, [
config,
trackingMode,
stockNature,
lowStockAt,
reorderQty,
warehouseId,
warranty,
config?.serialNumbersEnabled,
config?.batchListsEnabled,
@@ -585,7 +602,7 @@ export default function NewItemPage() {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
if (lowStockAt.trim()) {
if (config?.lowStockAlertsEnabled && lowStockAt.trim()) {
const point = Number(lowStockAt)
const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
@@ -629,8 +646,11 @@ export default function NewItemPage() {
let created = 0
const createdIds: number[] = []
try {
const effectiveStockNature = config?.stockNatureEnabled ? stockNature : "Stocked"
const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial"
!config?.trackingModeEnabled
? "None"
: !config?.serialNumbersEnabled && trackingMode === "Serial"
? "None"
: !config?.batchListsEnabled && trackingMode === "Batch"
? "None"
@@ -652,7 +672,7 @@ export default function NewItemPage() {
subCategoryId,
brandId,
baseUomId,
stockNature,
stockNature: effectiveStockNature,
trackingMode: effectiveTrackingMode,
warranty: effectiveWarranty,
warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
@@ -696,8 +716,9 @@ export default function NewItemPage() {
// Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like
// images — the items exist either way, and the rules can be fixed under Reorder settings.
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
if (lowStockAt.trim() && watched.length > 0) {
const alertWarehouseId = config?.alertWarehouseEnabled ? warehouseId : null
const watched = alertWarehouseId !== null ? [alertWarehouseId] : warehouses.map((w) => w.warehouseId)
if (config?.lowStockAlertsEnabled && lowStockAt.trim() && watched.length > 0) {
const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
let failed = 0
for (const itemId of createdIds) {
@@ -1000,6 +1021,7 @@ export default function NewItemPage() {
</p>
</div>
)}
{config?.stockNatureEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
@@ -1013,6 +1035,8 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
)}
{config?.trackingModeEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
@@ -1041,9 +1065,11 @@ export default function NewItemPage() {
)}
</p>
</div>
)}
</div>
</section>
{config?.lowStockAlertsEnabled && (
<section className={sectionClass}>
<div>
<h2 className="text-lg font-semibold text-foreground">Low stock alert</h2>
@@ -1097,6 +1123,7 @@ export default function NewItemPage() {
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p>
</div>
{config.alertWarehouseEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
@@ -1117,8 +1144,10 @@ export default function NewItemPage() {
</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-5 sm:gap-6 lg:grid-cols-2">
@@ -259,6 +259,12 @@ export default function ItemsPage() {
}
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
/** Stat-card filter: toggles the stock-level filter (same one as the "Stock level" dropdown). */
function filterByStock(next: ItemStockFilter | "All") {
setStock((current) => (current === next ? "All" : next))
setPage(1)
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
function changeCategory(next: number | "All") {
@@ -377,9 +383,9 @@ export default function ItemsPage() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{itemStats ? (
<>
<StatCard label="Total items" value={itemStats.total} icon={Package} />
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} />
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} />
<StatCard label="Total items" value={itemStats.total} icon={Package} onClick={() => filterByStock("All")} active={stock === "All"} />
<StatCard label="Low stock" value={itemStats.lowStock} icon={AlertTriangle} onClick={() => filterByStock("LowStock")} active={stock === "LowStock"} />
<StatCard label="Out of stock" value={itemStats.outOfStock} icon={PackageX} onClick={() => filterByStock("OutOfStock")} active={stock === "OutOfStock"} />
</>
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
@@ -426,8 +432,12 @@ export default function ItemsPage() {
]}
/>
)}
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
{config?.trackingModeEnabled && (
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
)}
{config?.stockNatureEnabled && (
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
)}
</>
}
>
@@ -56,6 +56,10 @@ export default function ProductSettingsPage() {
customVariantsEnabled: config.customVariantsEnabled,
itemImagesEnabled: config.itemImagesEnabled,
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
stockNatureEnabled: config.stockNatureEnabled,
trackingModeEnabled: config.trackingModeEnabled,
lowStockAlertsEnabled: config.lowStockAlertsEnabled,
alertWarehouseEnabled: config.alertWarehouseEnabled,
...patch,
},
etag ?? "",
@@ -224,6 +228,54 @@ export default function ProductSettingsPage() {
busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)}
/>
<ToggleRow
label="Stock nature"
description="Lets items be Stocked, Non-stocked or Service. Off hides the choice and new items are Stocked; existing items keep their nature."
checked={config.stockNatureEnabled}
busy={saving === "stockNatureEnabled"}
onChange={(v) => toggle("stockNatureEnabled", v)}
/>
<ToggleRow
label="Tracking mode"
description="Shows the tracking choice (None, Batch, Serial) on items. Off hides it and new items are untracked; existing items keep their mode."
checked={config.trackingModeEnabled}
busy={saving === "trackingModeEnabled"}
onChange={(v) => toggle("trackingModeEnabled", v)}
/>
<ToggleRow
label="Low stock alert"
description="Shows the low-stock alert (alert-at and reorder qty) on item create and the item page. Off hides it; alerts already set keep working."
checked={config.lowStockAlertsEnabled}
busy={saving === "lowStockAlertsEnabled"}
onChange={(v) =>
v
? toggle("lowStockAlertsEnabled", true)
: // The warehouse picker lives in the alert section, so switching it off turns that off too.
save(
"lowStockAlertsEnabled",
{ lowStockAlertsEnabled: false, alertWarehouseEnabled: false },
config.alertWarehouseEnabled
? "Low stock alert and alert warehouse disabled."
: "Low stock alert disabled.",
)
}
/>
<ToggleRow
label="Alert warehouse"
description={
config.lowStockAlertsEnabled
? "Lets the low-stock alert watch one chosen warehouse. Off: the alert watches every warehouse."
: "Needs Low stock alert switched on."
}
checked={config.alertWarehouseEnabled}
busy={saving === "alertWarehouseEnabled"}
disabled={!config.lowStockAlertsEnabled}
onChange={(v) => toggle("alertWarehouseEnabled", v)}
/>
</div>
{config.updatedAt && (
@@ -249,6 +301,10 @@ const LABELS: Record<keyof ProductConfig, string> = {
customVariantsEnabled: "Custom variants",
itemImagesEnabled: "Item images",
customWarrantyPeriodsEnabled: "Custom warranty periods",
stockNatureEnabled: "Stock nature",
trackingModeEnabled: "Tracking mode",
lowStockAlertsEnabled: "Low stock alert",
alertWarehouseEnabled: "Alert warehouse",
updatedAt: "Updated at",
updatedBy: "Updated by",
}
@@ -265,6 +321,10 @@ type FlagKey = keyof Pick<
| "customVariantsEnabled"
| "itemImagesEnabled"
| "customWarrantyPeriodsEnabled"
| "stockNatureEnabled"
| "trackingModeEnabled"
| "lowStockAlertsEnabled"
| "alertWarehouseEnabled"
>
function ToggleRow({
@@ -296,7 +296,13 @@ export default function GrnListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} />
{/* "Pending" = Draft (DashboardService) — the tile toggles the Status filter to Draft. */}
<KpiRow
tiles={stats ? [{
label: "Pending GRNs", value: stats.pendingGrns, icon: Clock,
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")), active: status === "Draft",
}] : null}
/>
<FilterBar
searchFirst
@@ -1,14 +1,15 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import {
ArrowLeft,
Banknote,
CreditCard,
FileDown,
Hash,
Landmark,
LineChart,
PiggyBank,
Receipt,
ReceiptText,
@@ -19,73 +20,26 @@ import {
import { dayEndReportApi } from "@/lib/api/day-end-report"
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 { formatAmount, formatReportDate, profitOrLoss } from "@/lib/format"
import { 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"
import { DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
import { useAuth } from "@/components/auth/AuthProvider"
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { KV, localIso, mobileCard } from "@/components/sales/day-end-report-parts"
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"
import { toast } from "@/components/ui/toast"
const MAX_RANGE_DAYS = 92
/** Local yyyy-MM-dd, `offsetDays` from today. */
function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
}
type Loaded<T> = { key: string; data: T | null; error: string | null }
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) {
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** 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")
/** "1 sale" / "12 sales". */
const salesCountLabel = (n: number) => `${n} sale${n === 1 ? "" : "s"}`
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
function ProfitStatement({ day }: { day: DayEndReportDay }) {
@@ -227,35 +181,33 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
)
}
export default function DayEndReportPage() {
// useSearchParams needs a Suspense boundary for the static build.
export default function DayEndReportRoute() {
return (
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
<DayEndReportPage />
</Suspense>
)
}
function DayEndReportPage() {
// ?date=yyyy-MM-dd opens a given day (Day-wise Income links here); otherwise today.
const dateParam = useSearchParams().get("date")
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
// their own branch server-side, whatever is sent.
const branchId = isAdmin ? activeBranchId : null
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
const [date, setDate] = useState(localIso())
const [from, setFrom] = useState(localIso(-29))
const [to, setTo] = useState(localIso())
const [downloading, setDownloading] = useState<"day" | "range" | null>(null)
const rangeDays = from && to ? daysBetween(from, to) : 0
const rangeError = !from || !to
? "Pick both dates"
: rangeDays < 1
? "'To' must be on or after 'From'"
: rangeDays > MAX_RANGE_DAYS
? `Pick at most ${MAX_RANGE_DAYS} days`
: null
// Fixed for the page — not user-editable.
const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : localIso()
const [downloading, setDownloading] = useState(false)
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
const dayKey = `${date}|${branchId}`
const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null)
const day = dayResult?.key === dayKey ? dayResult : null
const rangeKey = `${from}|${to}|${branchId}`
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
const range = rangeResult?.key === rangeKey ? rangeResult : null
useEffect(() => {
if (!date) return
@@ -269,39 +221,15 @@ export default function DayEndReportPage() {
}
}, [date, branchId, dayKey])
useEffect(() => {
if (rangeError) return
let cancelled = false
dayEndReportApi
.getDaily({ from, to, branchId })
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [from, to, branchId, rangeKey, rangeError])
async function downloadDay() {
if (!day?.data) return
setDownloading("day")
setDownloading(true)
try {
await downloadDayEndReportPdf(day.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(null)
}
}
async function downloadRange() {
if (!range?.data) return
setDownloading("range")
try {
await downloadDailyIncomePdf(range.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(null)
setDownloading(false)
}
}
@@ -313,7 +241,6 @@ export default function DayEndReportPage() {
// 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">
@@ -324,7 +251,7 @@ export default function DayEndReportPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Day-End Report</h1>
<p className="text-base text-muted-foreground">
A day&apos;s sales, how they were paid, its expenses and the resulting income — plus income day by day.
A day&apos;s sales, how they were paid, its expenses and the resulting income.
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
</p>
</div>
@@ -332,15 +259,23 @@ export default function DayEndReportPage() {
{/* --- One day ------------------------------------------------------------------------- */}
<section 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-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-2">
<Label htmlFor="der-date" className="text-base">Business date</Label>
<Input id="der-date" type="date" value={date} max={localIso()} onChange={(e) => setDate(e.target.value)} className="h-11 w-48 text-base" />
{/* Date on the right, above the Day-wise income / PDF buttons. */}
<div className="flex flex-col items-end gap-3">
{/* Fixed, not editable: today, or the day opened from Day-wise Income (?date=). */}
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-muted-foreground">Business date</span>
<span className="text-lg font-semibold text-foreground tabular-nums">{formatReportDate(date)}</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Link href="/dashboard/sales/day-wise-income" className={cn(buttonVariants({ variant: "outline" }))}>
<LineChart className="size-4" />
Day-wise income
</Link>
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading}>
<FileDown className="size-4" />
{downloading ? "Creating PDF…" : "Download day PDF"}
</Button>
</div>
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading !== null}>
<FileDown className="size-4" />
{downloading === "day" ? "Creating PDF…" : "Download day PDF"}
</Button>
</div>
{day?.error && <p className="text-sm text-destructive">{day.error}</p>}
@@ -354,29 +289,20 @@ export default function DayEndReportPage() {
{s && (
<>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-foreground">Sales</h2>
{/* Each amount card carries its sale count as the hint line, instead of a separate count card. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} />
<StatCard label="Paid amount" value={formatAmount(s.paidAmount)} icon={Wallet} />
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} />
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} />
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Sales count" value={String(s.salesCount)} icon={Hash} />
<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} />
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} hint={salesCountLabel(s.salesCount)} />
<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"}
label="Paid amount"
value={formatAmount(s.paidAmount)}
icon={Wallet}
hint={`${salesCountLabel(s.salesCount - s.unpaidSalesCount)} paid`}
/>
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} hint={`${salesCountLabel(s.cashSalesCount)} by cash`} />
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} hint={`${salesCountLabel(s.cardSalesCount)} by card`} />
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} hint={`${salesCountLabel(s.unpaidSalesCount)} not paid`} />
</div>
{/* Expenses and income cards live on Day-wise Income (Sales → Day-wise Income). */}
<p className="text-sm text-muted-foreground">
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "}
@@ -566,172 +492,6 @@ export default function DayEndReportPage() {
</>
)}
</section>
{/* --- Day-wise income ----------------------------------------------------------------- */}
<section 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-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<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="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-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-full sm:w-44" />
</div>
<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>
</div>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
{!rangeError && range?.data && (
<>
<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
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
value={formatSignedAmount(range.data.totals.income)}
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
/>
<StatCard
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
value={profitOrLoss(range.data.totals.netProfit).amount}
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
/>
</div>
{/* 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>
{/* 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>
<TableHead>Date</TableHead>
<TableHead className="text-right">Sales</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cash</TableHead>
<TableHead className="text-right">Card</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Expenses</TableHead>
<TableHead className="text-right">Income</TableHead>
<TableHead className="text-right">Profit / loss</TableHead>
<TableHead className="text-right">Deposited</TableHead>
<TableHead className="text-right">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
<IncomeCell value={d.income} />
<ProfitCell value={d.netProfit} />
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
<IncomeCell value={range.data.totals.income} bold />
<ProfitCell value={range.data.totals.netProfit} bold />
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
</div>
)
}
@@ -65,12 +65,13 @@ function isOverdue(row: SalesInvoiceSummary): boolean {
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
type PaymentFilter = "All" | "Unpaid" | "Paid"
type PaymentFilter = "All" | "Unpaid" | "Paid" | "Overdue"
const paymentFilters: { value: PaymentFilter; label: string }[] = [
{ value: "All", label: "All payments" },
{ value: "Unpaid", label: "Balance due" },
{ value: "Paid", label: "Fully paid" },
{ value: "Overdue", label: "Overdue" },
]
export default function ApprovedInvoicesPage() {
@@ -155,7 +156,8 @@ function ApprovedInvoices() {
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom,
to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
overdue: salesPayment === "Overdue" ? true : undefined,
q: salesQuery || undefined,
})
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
@@ -189,7 +191,8 @@ function ApprovedInvoices() {
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
from: salesFrom,
to: salesTo,
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
paid: salesPayment === "Paid" ? true : salesPayment === "Unpaid" ? false : undefined,
overdue: salesPayment === "Overdue" ? true : undefined,
q: salesQuery || undefined,
})
.then((data) => setTotalsResult({ key: totalsKey, data }))
@@ -265,6 +268,8 @@ function ApprovedInvoices() {
<InvoiceTotalsCards
totals={cardTotals}
payment={salesPayment}
onPaymentChange={(next) => changeSalesPayment(salesPayment === next ? "All" : next)}
pageOnly={pageOnly}
scope={[
salesCashier === "All" ? "All cashiers" : (cashiers.find((c) => c.userId === salesCashier)?.displayName ?? "Cashier"),
@@ -662,8 +667,15 @@ const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow
* `undefined` = loading. Same StatCard as the other screens' KPI rows.
*/
function InvoiceTotalsCards({
totals, scope, pageOnly,
}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) {
totals, scope, pageOnly, payment, onPaymentChange,
}: {
totals: SalesInvoiceListTotals | null | undefined
scope: string
pageOnly: boolean
/** The payment filter the cards toggle — clicking a card filters the list (again = clear). */
payment: PaymentFilter
onPaymentChange: (next: PaymentFilter) => void
}) {
if (totals === undefined) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@@ -682,9 +694,9 @@ function InvoiceTotalsCards({
{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="Invoices" value={totals.count} icon={FileText} hint={`${totals.unpaidCount} with balance due`} onClick={() => onPaymentChange("All")} active={payment === "All"} />
<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="Paid" value={formatAmount(totals.paidTotal)} icon={CheckCircle2} hint="Received so far" iconClassName="bg-success/10 text-success" onClick={() => onPaymentChange("Paid")} active={payment === "Paid"} />
<StatCard
label="Balance due"
value={formatAmount(totals.balanceTotal)}
@@ -692,6 +704,8 @@ function InvoiceTotalsCards({
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}
onClick={() => onPaymentChange("Unpaid")}
active={payment === "Unpaid"}
/>
<StatCard
label="Overdue"
@@ -700,6 +714,8 @@ function InvoiceTotalsCards({
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}
onClick={() => onPaymentChange("Overdue")}
active={payment === "Overdue"}
/>
</div>
</div>
@@ -0,0 +1,277 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, FileDown, Receipt, ReceiptText, TrendingDown, TrendingUp } from "lucide-react"
import { dayEndReportApi } from "@/lib/api/day-end-report"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils"
import { useClientPagination } from "@/hooks/use-client-pagination"
import { DayEndDailyReport } from "@/types/sales"
import { useAuth } from "@/components/auth/AuthProvider"
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { IncomeCell, KV, localIso, mobileCard, ProfitCell } from "@/components/sales/day-end-report-parts"
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"
import { toast } from "@/components/ui/toast"
const MAX_RANGE_DAYS = 92
function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
}
type Loaded<T> = { key: string; data: T | null; error: string | null }
/**
* Day-wise income (Sales → Day-wise Income): sales, expenses, income and profit/loss per day over a range
* (≤ 92 days), with a chart and a PDF. Clicking a day opens that day in the Day-End Report.
*/
export default function DayWiseIncomePage() {
const router = useRouter()
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
// their own branch server-side, whatever is sent.
const branchId = isAdmin ? activeBranchId : null
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
const [from, setFrom] = useState(localIso(-29))
const [to, setTo] = useState(localIso())
const [downloading, setDownloading] = useState(false)
const rangeDays = from && to ? daysBetween(from, to) : 0
const rangeError = !from || !to
? "Pick both dates"
: rangeDays < 1
? "'To' must be on or after 'From'"
: rangeDays > MAX_RANGE_DAYS
? `Pick at most ${MAX_RANGE_DAYS} days`
: null
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
const rangeKey = `${from}|${to}|${branchId}`
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
const range = rangeResult?.key === rangeKey ? rangeResult : null
useEffect(() => {
if (rangeError) return
let cancelled = false
dayEndReportApi
.getDaily({ from, to, branchId })
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [from, to, branchId, rangeKey, rangeError])
async function downloadRange() {
if (!range?.data) return
setDownloading(true)
try {
await downloadDailyIncomePdf(range.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(false)
}
}
function openDay(date: string | null | undefined) {
if (date) router.push(`/dashboard/sales/day-end-report?date=${date}`)
}
// Paged in the browser (the report returns whole lists); a new range starts on page 1.
const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`)
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" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Day-wise Income</h1>
<p className="text-base text-muted-foreground">
Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days). Click a day to open its Day-End Report.
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
</p>
</div>
</div>
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="grid grid-cols-2 items-end gap-3 sm:flex sm:flex-wrap sm:justify-end">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dwi-from">From</Label>
<Input id="dwi-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="dwi-to">To</Label>
<Input id="dwi-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-full sm:w-44" />
</div>
<Button className="col-span-2 sm:col-span-1" onClick={downloadRange} disabled={!range?.data || downloading}>
<FileDown className="size-4" />
{downloading ? "Creating PDF…" : "Download PDF"}
</Button>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
{!rangeError && range?.data && (
<>
<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}
hint={`${range.data.totals.salesCount} sale${range.data.totals.salesCount === 1 ? "" : "s"}`}
/>
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
value={formatSignedAmount(range.data.totals.income)}
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
/>
<StatCard
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
value={profitOrLoss(range.data.totals.netProfit).amount}
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
/>
</div>
{/* 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>
{/* Phones: one card per day (newest first), tap to open that day's report. */}
<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={() => openDay(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>
<TableHead>Date</TableHead>
<TableHead className="text-right">Sales</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cash</TableHead>
<TableHead className="text-right">Card</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Expenses</TableHead>
<TableHead className="text-right">Income</TableHead>
<TableHead className="text-right">Profit / loss</TableHead>
<TableHead className="text-right">Deposited</TableHead>
<TableHead className="text-right">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dayPage.pageRows.map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => openDay(d.date)} title="Open this day's Day-End Report">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
<IncomeCell value={d.income} />
<ProfitCell value={d.netProfit} />
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
<IncomeCell value={range.data.totals.income} bold />
<ProfitCell value={range.data.totals.netProfit} bold />
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{dayPage.pagination.totalItems > 10 && (
<PaginationFooter pagination={dayPage.pagination} onPageChange={dayPage.setPage} pageSizeOptions={[10, 25, 50]} onPageSizeChange={dayPage.setPageSize} />
)}
</>
)}
</section>
</div>
)
}
@@ -48,10 +48,23 @@ export default function UsersPage() {
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
// Set by clicking a stat card (click again to clear) — narrows the list to that group.
const [cardFilter, setCardFilter] = useState<"All" | "Active" | "Inactive" | "Admins">("All")
function toggleCardFilter(next: "Active" | "Inactive" | "Admins") {
setCardFilter((current) => (current === next ? "All" : next))
setPage(1)
}
function load() {
usersApi
.list({ page, pageSize, q: debouncedSearch || undefined })
.list({
page,
pageSize,
q: debouncedSearch || undefined,
status: cardFilter === "Active" || cardFilter === "Inactive" ? cardFilter : undefined,
isAdmin: cardFilter === "Admins" ? true : undefined,
})
.then((res) => {
setUsers(res.items)
setPagination(res.pagination)
@@ -65,7 +78,7 @@ export default function UsersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch])
useEffect(load, [page, pageSize, debouncedSearch, cardFilter])
function loadStats() {
Promise.all([
@@ -138,10 +151,10 @@ export default function UsersPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? (
<>
<StatCard label="Total users" value={stats.total} icon={UsersIcon} />
<StatCard label="Active" value={stats.active} icon={UserCheck} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} />
<StatCard label="Total users" value={stats.total} icon={UsersIcon} onClick={() => { setCardFilter("All"); setPage(1) }} active={cardFilter === "All"} />
<StatCard label="Active" value={stats.active} icon={UserCheck} onClick={() => toggleCardFilter("Active")} active={cardFilter === "Active"} />
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} onClick={() => toggleCardFilter("Inactive")} active={cardFilter === "Inactive"} />
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} onClick={() => toggleCardFilter("Admins")} active={cardFilter === "Admins"} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
@@ -43,25 +43,27 @@ export default function CountsListPage() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
// Set by the "Pending Counts" tile (click again to clear). "Pending" = Counted, awaiting posting (DashboardService).
const [pendingOnly, setPendingOnly] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query])
useEffect(() => setPage(1), [query, pendingOnly])
useEffect(() => {
setError(null)
stockCountsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.list({ page, pageSize, q: query || undefined, status: pendingOnly ? "Counted" : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => {
setCounts(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
}, [page, pageSize, query, pendingOnly, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
@@ -73,7 +75,7 @@ export default function CountsListPage() {
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
const hasFilters = query.length > 0 || pendingOnly
return (
<div className="flex flex-col gap-6">
@@ -90,7 +92,7 @@ export default function CountsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} />
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList, onClick: () => setPendingOnly((v) => !v), active: pendingOnly }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
+14 -158
View File
@@ -9,7 +9,6 @@ import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
@@ -19,15 +18,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
@@ -35,14 +25,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
import { CreateVendorDialog } from "@/components/vendors/CreateVendorDialog"
type StatusFilter = EntityStatus | "All"
@@ -53,10 +36,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
export default function VendorsPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -67,14 +49,7 @@ export default function VendorsPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("LKR")
const { config: procConfig } = useProcurementConfig()
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Separate from the paginated table list above — this needs every existing code (up to the
// server's page-size cap) to de-dupe against, not just the current page's 5 rows.
@@ -85,8 +60,6 @@ export default function VendorsPage() {
const [editingId, setEditingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
@@ -131,56 +104,6 @@ export default function VendorsPage() {
const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function resetForm() {
setName("")
setTerms("")
setTaxReg("")
setCurrency("LKR")
setOptional(EMPTY_VENDOR_OPTIONAL)
setBranchFilterId(null)
setErrors({})
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({ code: generatedCode, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: effectiveBranchId ?? 0 })
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false)
resetForm()
load()
setAllVendorCodes((codes) => [...codes, result.data.code])
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
// Hard delete. The server refuses (409) a vendor any purchase document or item still
// references — those can only be deactivated, from the detail page's status toggle.
async function handleDelete(vendor: Vendor) {
@@ -218,85 +141,18 @@ export default function VendorsPage() {
<p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg">
<Plus className="size-5" />
New Vendor
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="v-name" required>Name</FieldLabel>
<Input id="v-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="v-code">Code (auto-generated)</FieldLabel>
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearError("mobile")
clearError("email")
}}
idPrefix="v"
errors={errors}
/>
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="v-currency" required>Currency</FieldLabel>
<Input id="v-currency" value={currency} onChange={(e) => { setCurrency(e.target.value); clearError("currency") }} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</Field>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="v-branch" required>Branch</FieldLabel>
<Select<number | null> value={branchFilterId} onValueChange={(v) => { setBranchFilterId(v); clearError("branch") }}>
<SelectTrigger id="v-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</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}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
<Button size="lg" onClick={() => setOpen(true)}>
<Plus className="size-5" />
New Vendor
</Button>
<CreateVendorDialog
open={open}
onOpenChange={setOpen}
onCreated={(vendor) => {
load()
setAllVendorCodes((codes) => [...codes, vendor.code])
}}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -33,6 +33,7 @@ import {
LayoutList,
LayoutTemplate,
LineChart,
CalendarRange,
ListTree,
Lock,
MapPin,
@@ -138,6 +139,8 @@ export const navItems: {
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart },
// Split out of the Day-End Report page; same data and permission code, so no seeded NavItem of its own.
{ title: "Day-wise Income", code: "sales.day-end-report", href: "/dashboard/sales/day-wise-income", icon: CalendarRange },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
@@ -109,6 +109,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-wise-income") return "Day-wise Income"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report"
if (pathname === "/dashboard/hrm") return "HRM"
@@ -78,6 +78,8 @@ export function PurchaseOrderListView({
actionsHeader = "Actions",
emptyMessage,
onTotalChange,
status: controlledStatus,
onStatusChange,
}: {
/** Pins the list to one status (the approval queue); hides the status filter. */
fixedStatus?: PurchaseOrderStatus
@@ -88,6 +90,9 @@ export function PurchaseOrderListView({
emptyMessage?: ReactNode
/** Total rows matching the current filters, e.g. for a count badge in the page header. */
onTotalChange?: (total: number | null) => void
/** Optional controlled status filter — lets the page drive it (e.g. from a stat card). */
status?: PurchaseOrderStatus | "All"
onStatusChange?: (status: PurchaseOrderStatus | "All") => void
}) {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
@@ -103,7 +108,9 @@ export function PurchaseOrderListView({
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [innerStatus, setInnerStatus] = useState<StatusFilter>("All")
const status = controlledStatus ?? innerStatus
const setStatus = onStatusChange ?? setInnerStatus
const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" })
const [sortKey, setSortKey] = useState<SortKey>("created")
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
@@ -27,6 +27,7 @@ export function WarrantyFields({
onChange,
error,
disabled,
hideLabel,
}: {
config: ProductConfig
/** Active rows of the Warranty Periods master, in months. */
@@ -36,6 +37,8 @@ export function WarrantyFields({
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
error?: string
disabled?: boolean
/** Leave out the "Warranty" label when the surrounding section already has that heading. */
hideLabel?: boolean
}) {
const groups = warrantyPeriodGroups(config, customMonths)
// The item's own length when it is not one of the offered periods (a one-off custom period saved earlier).
@@ -71,7 +74,7 @@ export function WarrantyFields({
return (
<div className="flex flex-col gap-3">
<Label className="text-base">Warranty</Label>
{!hideLabel && <Label className="text-base">Warranty</Label>}
<RadioGroup
value={warranty}
onValueChange={(v) => {
@@ -0,0 +1,48 @@
import { formatSignedAmount, profitOrLoss } from "@/lib/format"
import { cn } from "@/lib/utils"
import { TableCell } from "@/components/ui/table"
/** Shared by the Day-End Report and Day-wise Income pages. */
/** Local yyyy-MM-dd, `offsetDays` from today. */
export function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
export function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) {
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
export function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */
export 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. */
export 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")
@@ -7,6 +7,9 @@ export interface KpiTileSpec {
label: string
value: number
icon?: LucideIcon
/** Makes the tile a filter toggle for the list below (see StatCard). */
onClick?: () => void
active?: boolean
}
/**
@@ -20,7 +23,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
{tiles
? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} onClick={tile.onClick} active={tile.active} />
))
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
</div>
@@ -58,6 +58,10 @@ export interface StatCardProps {
valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string
/** Makes the card a toggle button that filters the list below it (click again to clear). */
onClick?: () => void
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
active?: boolean
}
/**
@@ -76,18 +80,29 @@ export function StatCard({
className,
valueClassName,
iconClassName,
onClick,
active = false,
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive)
// Clickable cards are real buttons (keyboard + screen reader); plain cards stay divs.
const Root = onClick ? "button" : "div"
return (
// A size container: the card reads its *own* width, so it lays out the same whether a page puts
// it in a 2-up phone grid or a wide desktop row.
<div
<Root
{...(onClick
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
: {})}
className={cn(
"@container min-w-0 rounded-2xl bg-card px-4 py-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className
"@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
// After className so the "selected" look wins over a card's own tint.
active && "bg-primary/5 ring-2 ring-primary"
)}
>
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
@@ -122,10 +137,11 @@ export function StatCard({
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
) : (
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p>
// Wraps rather than truncating, so a hint like "12 sales by card" is never cut off in a narrow card.
hint && <p className="text-xs leading-snug wrap-break-word text-muted-foreground">{hint}</p>
)}
</div>
</div>
</div>
</Root>
)
}
@@ -0,0 +1,200 @@
"use client"
import { useEffect, useState } from "react"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
import { useProcurementConfig } from "@/hooks/use-procurement-config"
import {
EMPTY_VENDOR_OPTIONAL,
VendorOptionalFields,
VendorOptionalValues,
validateVendorOptional,
vendorOptionalPayload,
vendorTermsTaxPayload,
} from "@/components/vendors/VendorOptionalFields"
/** Currency isn't asked for on create; new vendors get this (the server requires one). Editable later on the vendor page. */
const DEFAULT_VENDOR_CURRENCY = "LKR"
/**
* The one "New vendor" modal — used by the Vendors list and by the New Purchase Order vendor picker's "+" button,
* so both create vendors the same way. The code is generated from the name (de-duped against every existing code),
* the optional fields follow Procurement Configuration, and the branch follows the header's branch switcher
* (an admin with no branch picked chooses one here).
*/
export function CreateVendorDialog({
open,
onOpenChange,
onCreated,
description = "Create a supplier record. Its code is generated from the name.",
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (vendor: Vendor) => void
description?: string
}) {
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const { config: procConfig } = useProcurementConfig()
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
const [pickedBranchId, setPickedBranchId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Every existing code (up to the server's page-size cap), not just one page, so the generated code is unique.
const [existingCodes, setExistingCodes] = useState<string[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? pickedBranchId) : myBranchId) : null
const generatedCode = name.trim() ? generateVendorCode(name, existingCodes) : ""
useEffect(() => {
if (!open) return
vendorsApi.list({ pageSize: 200 }).then((res) => setExistingCodes(res.items.map((v) => v.code))).catch(() => {})
}, [open])
useEffect(() => {
if (open && showBranchPicker) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [open, showBranchPicker])
function reset() {
setName("")
setTerms("")
setTaxReg("")
setOptional(EMPTY_VENDOR_OPTIONAL)
setPickedBranchId(null)
setErrors({})
}
function close(next: boolean) {
onOpenChange(next)
if (!next) reset()
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedCode,
name,
...vendorTermsTaxPayload(procConfig, terms, taxReg),
currency: DEFAULT_VENDOR_CURRENCY,
...vendorOptionalPayload(procConfig, optional),
branchId: effectiveBranchId ?? 0,
})
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
onCreated(result.data)
close(false)
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cv-name" required>Name</FieldLabel>
<Input id="cv-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cv-code">Code (auto-generated)</FieldLabel>
<Input id="cv-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{procConfig.vendorPaymentTermsEnabled && (
<Field>
<FieldLabel htmlFor="cv-terms">Payment terms (optional)</FieldLabel>
<Input id="cv-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
)}
{procConfig.vendorTaxRegEnabled && (
<Field>
<FieldLabel htmlFor="cv-taxreg">Tax registration (optional)</FieldLabel>
<Input id="cv-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
)}
<VendorOptionalFields
config={procConfig}
values={optional}
onChange={(patch) => {
setOptional((prev) => ({ ...prev, ...patch }))
clearError("mobile")
clearError("email")
}}
idPrefix="cv"
errors={errors}
/>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="cv-branch" required>Branch</FieldLabel>
<Select<number | null> value={pickedBranchId} onValueChange={(v) => { setPickedBranchId(v); clearError("branch") }}>
<SelectTrigger id="cv-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</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={() => close(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+2
View File
@@ -40,6 +40,8 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
createdBy?: number
/** true = fully paid, false = balance still due. */
paid?: boolean
/** true = past its due date with a balance still owed. */
overdue?: boolean
/** Matches invoice number or customer name. */
q?: string
}
+12
View File
@@ -394,6 +394,14 @@ export interface ProductConfig {
itemImagesEnabled: boolean
/** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */
customWarrantyPeriodsEnabled: boolean
/** Server-enforced on item writes: off = new items are Stocked; existing natures are kept. */
stockNatureEnabled: boolean
/** Server-enforced on item writes: off = new items are untracked (None); existing modes are kept. */
trackingModeEnabled: boolean
/** Advisory: shows the low-stock alert section on item screens. Saved reorder rules keep working. */
lowStockAlertsEnabled: boolean
/** Advisory: lets the low-stock alert watch one warehouse. Needs `lowStockAlertsEnabled`. */
alertWarehouseEnabled: boolean
updatedAt: string | null
updatedBy: number | null
}
@@ -410,6 +418,10 @@ export interface UpdateProductConfigRequest {
customVariantsEnabled: boolean
itemImagesEnabled: boolean
customWarrantyPeriodsEnabled: boolean
stockNatureEnabled: boolean
trackingModeEnabled: boolean
lowStockAlertsEnabled: boolean
alertWarehouseEnabled: boolean
}
/**