product and sales fixers

This commit is contained in:
2026-09-30 17:06:28 +05:30
parent 9fd290e256
commit cea433e320
98 changed files with 85367 additions and 1516 deletions
@@ -519,7 +519,7 @@ export default function EmployeesPage() {
<AlertDialogContent
variant="warning"
title="This employee has login access"
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login access, or just deactivate it?`}
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login credentials? This cannot be undone — they will no longer be able to sign in, and the user will be removed from Settings > Users. Choose "Keep, but Deactivate" to only disable the login.`}
confirmLabel="Delete User Access"
cancelLabel="Keep, but Deactivate"
onConfirm={() => { handleDelete(userAccountChoiceFor, true); setUserAccountChoiceFor(null) }}
@@ -0,0 +1,14 @@
import { Loader2 } from "lucide-react"
/**
* Shown inside the dashboard shell (sidebar and header stay put) while the next page's code
* loads — including programmatic navigations the top progress bar can't see start.
*/
export default function DashboardLoading() {
return (
<div role="status" aria-live="polite" className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
<Loader2 className="size-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Loading…</span>
</div>
)
}
@@ -7,6 +7,7 @@ import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -35,6 +36,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
@@ -115,6 +117,8 @@ export default function ItemDetailPage() {
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [imagesEnabled, setImagesEnabled] = useState(false)
const [imagesVersion, setImagesVersion] = useState(0)
function applyItem(data: Item) {
setItem(data)
@@ -162,6 +166,7 @@ export default function ItemDetailPage() {
setTaxRates(tr.items)
})
.catch(() => {})
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
@@ -254,6 +259,7 @@ export default function ItemDetailPage() {
function closeEdit() {
if (item) applyItem(item)
setEditOpen(false)
setImagesVersion((v) => v + 1)
}
async function handleSave() {
@@ -282,6 +288,7 @@ export default function ItemDetailPage() {
applyItem(result.data)
setEtag(result.etag)
setEditOpen(false)
setImagesVersion((v) => v + 1)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
@@ -408,6 +415,10 @@ export default function ItemDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
{/* View-only here; images are added/removed from the Edit dialog. Re-keyed when that
dialog closes so this copy picks up what changed there. */}
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
@@ -828,6 +839,7 @@ export default function ItemDetailPage() {
</Select>
</div>
</div>
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
@@ -1,375 +1,26 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Plus, Ruler, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Uom } from "@/types/master-data"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
type Tab = "brand" | "uom"
// Large enough to show "all brands" in one grid — the chip layout replaces the
// old table's pagination, so we just ask for everything in one page.
const ALL_ITEMS_PAGE_SIZE = 500
export default function BrandsPage() {
// Brand (and UOM) now live as tabs on the merged Category/Brand/UOM page — this
// route stays only to keep old links/bookmarks (incl. ?tab=uom) working.
export default function BrandsRedirectPage() {
return (
<Suspense>
<BrandsPageContent />
<BrandsRedirect />
</Suspense>
)
}
function BrandsPageContent() {
function BrandsRedirect() {
const router = useRouter()
const searchParams = useSearchParams()
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
const [tab, setTab] = useState<Tab>(initialTab)
const tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
function selectTab(next: Tab) {
setTab(next)
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
}
useEffect(() => {
router.replace(`/dashboard/products/categories?tab=${tab}`)
}, [router, tab])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
</div>
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
<button
type="button"
onClick={() => selectTab("brand")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
Brand
</button>
<button
type="button"
onClick={() => selectTab("uom")}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
UOM
</button>
</div>
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
</div>
)
}
function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [editName, setEditName] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
function load() {
setError(null)
brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setTogglingId(brand.brandId)
try {
await brandsApi.updateStatus(brand.brandId, next)
load()
} catch (err) {
toast.error("Could not update brand status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
function openEdit(brand: Brand) {
setEditing(brand)
setEditName(brand.name)
setEditErrors({})
setEditOpen(true)
}
async function handleEditSubmit() {
if (!editing) return
const nextErrors = validateBrandName(editName)
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setEditSubmitting(true)
try {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
toast.success("Brand updated", result.data.name)
setEditOpen(false)
setEditing(null)
load()
} catch (err) {
setEditErrors({ name: errorMessage(err) })
toast.error("Could not update brand", errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add Brand
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && brands === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && brands !== null && brands.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No brands yet.</p>
</div>
)}
{!error && brands !== null && brands.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{brands.map((b) => (
<label
key={b.brandId}
onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId}
className="shrink-0"
/>
<span className="truncate font-medium">{b.name}</span>
</label>
))}
</div>
)}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit brand</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
<Input
id="brand-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-invalid={!!editErrors.name}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.name } : 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={() => setEditOpen(false)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(uom: Uom) {
const next = uom.status === "Active" ? "Inactive" : "Active"
setTogglingId(uom.uomId)
try {
await uomsApi.updateStatus(uom.uomId, next)
load()
} catch (err) {
toast.error("Could not update UOM status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add UOM
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && uoms === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && uoms !== null && uoms.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Ruler className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No UOMs yet.</p>
</div>
)}
{!error && uoms !== null && uoms.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{uoms.map((u) => (
<label
key={u.uomId}
title="Tick to activate/deactivate"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId}
className="shrink-0"
/>
<span className="truncate font-medium">{u.name}</span>
</label>
))}
</div>
)}
</div>
)
return null
}
@@ -1,484 +1,76 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { Suspense, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Category, ProductConfig } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { BrandPanel } from "@/components/products/BrandPanel"
import { CategoryPanel } from "@/components/products/CategoryPanel"
import { UomPanel } from "@/components/products/UomPanel"
type SortOrder = "asc" | "desc"
type SortKey = "categoryId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
type Tab = "category" | "brand" | "uom"
const PAGE_SIZE = 25
const TABS: { value: Tab; label: string }[] = [
{ value: "category", label: "Category" },
{ value: "brand", label: "Brand" },
{ value: "uom", label: "UOM" },
]
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const BASE_HREF = "/dashboard/products/categories"
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
// always returns Name ascending, so this doesn't hold across page turns or other columns.
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
function parseTab(value: string | null): Tab {
return value === "brand" || value === "uom" ? value : "category"
}
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Category | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null)
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
/**
* Category, Brand and UOM masters on one page as tabs. The old /brands and /uoms
* routes redirect here with `?tab=` so existing links/bookmarks keep working.
*/
export default function ProductMastersPage() {
return (
<Suspense>
<ProductMastersPageContent />
</Suspense>
)
}
useEffect(() => {
productConfig().then(setConfig).catch(() => setConfig(null))
}, [])
function ProductMastersPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [tab, setTab] = useState<Tab>(parseTab(searchParams.get("tab")))
// Subcategories are their own resource — there's no count on the Category DTO itself,
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
// is needed) once the feature is on and the page's categories are known.
useEffect(() => {
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
setSubCategoryCounts({})
return
}
let cancelled = false
Promise.all(
categories.map((c) =>
categoriesApi
.listSubCategories(c.categoryId, { pageSize: 1 })
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
.catch(() => [c.categoryId, 0] as const)
)
).then((entries) => {
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
})
return () => {
cancelled = true
}
}, [config?.subcategoriesEnabled, categories])
useEffect(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
}, [search, status])
function load() {
setError(null)
categoriesApi
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setCategories(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [search, status, page])
const hasFilters = search.trim().length > 0 || status !== "All"
const sortedCategories = categories
? [...categories].sort((a, b) => {
const cmp =
sortKey === "categoryId"
? a.categoryId - b.categoryId
: sortKey === "createdAt"
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: a[sortKey].localeCompare(b[sortKey])
return sortOrder === "asc" ? cmp : -cmp
})
: null
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
function openCreateDialog() {
setEditing(null)
setName("")
setErrors({})
setOpen(true)
}
function openEditDialog(category: Category) {
setEditing(category)
setName(category.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
let result
if (editing) {
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
const current = await categoriesApi.get(editing.categoryId)
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
} else {
result = await categoriesApi.create({ name })
}
toast.success(editing ? "Category updated" : "Category created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(category: Category) {
const next = category.status === "Active" ? "Inactive" : "Active"
setDeletingId(category.categoryId)
try {
await categoriesApi.updateStatus(category.categoryId, next)
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
load()
} catch (err) {
toast.error("Could not update category status", errorMessage(err))
} finally {
setDeletingId(null)
}
function selectTab(next: Tab) {
setTab(next)
router.replace(next === "category" ? BASE_HREF : `${BASE_HREF}?tab=${next}`, { scroll: false })
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
<DialogDescription>Give the category a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : 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={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
<div>
<h1 className="text-2xl font-bold text-foreground">Category/Brand/UOM</h1>
<p className="text-base text-muted-foreground">Manage item categories, brands and units of measure.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search categories…"
className="h-14 w-full pl-11 text-base"
aria-label="Search categories"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
{TABS.map((t) => (
<button
key={t.value}
type="button"
onClick={() => selectTab(t.value)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
tab === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
{t.label}
</button>
))}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && categories === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && categories !== null && categories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ListTree className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No categories match your search/filter." : "No categories yet."}
</p>
</div>
)}
{!error && categories !== null && categories.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{sortedCategories!.map((c) => (
<div key={c.categoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{c.name}</p>
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
{config?.subcategoriesEnabled && (
<div>
<p className="text-muted-foreground">Subcategories</p>
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
</div>
)}
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedCategories!.map((c) => (
<TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"border-transparent",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
)}
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
{tab === "category" && <CategoryPanel />}
{tab === "brand" && <BrandPanel />}
{tab === "uom" && <UomPanel />}
</div>
)
}
function SortableHeader({
label,
active,
order,
onClick,
}: {
label: string
active: boolean
order: SortOrder
onClick: () => void
}) {
return (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
@@ -81,7 +81,7 @@ export default function ItemTypesPage() {
} else {
await itemTypesApi.create({ name, isMeasurable })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
toast.success(editing ? "Item variant updated" : "Item variant created", name)
setOpen(false)
setName("")
setIsMeasurable(false)
@@ -90,7 +90,7 @@ export default function ItemTypesPage() {
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
toast.error(editing ? "Could not update item variant" : "Could not create item variant", errorMessage(err))
} finally {
setSubmitting(false)
}
@@ -102,7 +102,7 @@ export default function ItemTypesPage() {
setTogglingId(itemType.itemTypeId)
try {
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
toast.success(next === "Inactive" ? "Item variant deactivated" : "Item variant activated", itemType.name)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
@@ -116,7 +116,7 @@ export default function ItemTypesPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<h1 className="text-2xl font-bold text-foreground">Item Variants</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
A <span className="font-medium">measurement</span> dimension captures a number plus a unit instead, which becomes each item&apos;s content size.
@@ -125,11 +125,11 @@ export default function ItemTypesPage() {
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</Button>} />
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Variant</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
<DialogDescription>Give the item type a name.</DialogDescription>
<DialogTitle>{editing ? "Edit item variant" : "New item variant"}</DialogTitle>
<DialogDescription>Give the item variant a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
@@ -188,7 +188,7 @@ export default function ItemTypesPage() {
{!error && itemTypes !== null && itemTypes.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SwatchBook className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No item types yet.</p>
<p className="text-base text-muted-foreground">No item variants yet.</p>
</div>
)}
@@ -1,15 +1,16 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Fragment, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Plus, X } from "lucide-react"
import { ImagePlus, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { itemTypesApi } from "@/lib/api/item-types"
import { productConfig } from "@/lib/api/product-config"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
@@ -17,9 +18,11 @@ import {
contentPairErrors,
validateVariantContent,
validateVariantItemForm,
validateItemTypeName,
validateVariantPrices,
} from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
import {
Brand,
Category,
@@ -28,7 +31,6 @@ import {
ProductConfig,
StockNature,
SubCategory,
WARRANTY_PERIOD_MONTHS_OPTIONS,
Warranty,
WarrantyPeriodMonths,
} from "@/types/master-data"
@@ -43,10 +45,24 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import {
ITEM_IMAGE_ACCEPT,
ITEM_IMAGE_MAX_COUNT,
itemImageProblem,
} from "@/components/products/ItemImagesPanel"
function skuSegment(text: string, maxLen: number): string {
const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "")
@@ -105,6 +121,8 @@ export default function NewItemPage() {
// Kept as a raw string: an empty box means "no content size", which is different from 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
/** Form-level unit for size variants — the default unit of every size value added below. */
const [measurementUnit, setMeasurementUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
const [loadError, setLoadError] = useState<string | null>(null)
@@ -130,6 +148,23 @@ export default function NewItemPage() {
// submit, without having to remove and re-add the whole value that produced it.
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Inline "custom variant" creation — only offered when config.customVariantsEnabled.
const [customOpen, setCustomOpen] = useState(false)
const [customName, setCustomName] = useState("")
// Ticked ⇒ the custom variant is a size (measurement) dimension, valued in `measurementUnit`.
const [customIsSize, setCustomIsSize] = useState(false)
const [customError, setCustomError] = useState<string | null>(null)
// Item images (config.itemImagesEnabled): the items don't exist until submit, so picks are
// held locally with object-URL previews and uploaded to every created variant afterwards.
const [pendingImages, setPendingImages] = useState<{ file: File; url: string }[]>([])
const imageInputRef = useRef<HTMLInputElement>(null)
const pendingImagesRef = useRef(pendingImages)
useEffect(() => {
pendingImagesRef.current = pendingImages
}, [pendingImages])
useEffect(() => () => pendingImagesRef.current.forEach((p) => URL.revokeObjectURL(p.url)), [])
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
// each row keeps its own flat price in `pricesByKey`.
@@ -141,6 +176,15 @@ export default function NewItemPage() {
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
// Active rows of the Warranty Periods master (months), offered when config.customWarrantyPeriodsEnabled.
const [customWarrantyMonths, setCustomWarrantyMonths] = useState<number[]>([])
// Inline "custom period" — adds to that master without leaving the form.
const [customPeriodOpen, setCustomPeriodOpen] = useState(false)
const [customPeriodAmount, setCustomPeriodAmount] = useState("")
const [customPeriodUnit, setCustomPeriodUnit] = useState<WarrantyInputUnit>("months")
const [customPeriodError, setCustomPeriodError] = useState<string | null>(null)
// A length entered via "Custom period" — for this item only, never saved to the master.
const [itemOnlyWarrantyMonths, setItemOnlyWarrantyMonths] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
@@ -169,6 +213,11 @@ export default function NewItemPage() {
setWarehouses(wh.items)
})
.catch((err) => setLoadError(errorMessage(err)))
// Optional extra: a failure here just means only the default periods are offered.
warrantyPeriodsApi
.list({ status: "Active" })
.then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months)))
.catch(() => {})
}, [])
// Subcategories are their own resource now — fetched per category rather than filtered
@@ -213,6 +262,22 @@ export default function NewItemPage() {
config?.sellingPricesEnabled,
])
const warrantyGroups = warrantyPeriodGroups(config, customWarrantyMonths)
// Dropdown sections: defaults, the shared presets, then this item's own length (if any and
// not already one of the others).
const warrantySelectGroups = [
{ label: "Default periods", months: warrantyGroups.defaults },
{ label: "Custom periods", months: warrantyGroups.custom },
{
label: "This item only",
months:
config?.customWarrantyPeriodsEnabled &&
itemOnlyWarrantyMonths !== null &&
![...warrantyGroups.defaults, ...warrantyGroups.custom].includes(itemOnlyWarrantyMonths)
? [itemOnlyWarrantyMonths]
: [],
},
].filter((g) => g.months.length > 0)
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
@@ -234,6 +299,98 @@ export default function NewItemPage() {
)
}
function addPendingImages(files: FileList | null) {
if (!files) return
const room = ITEM_IMAGE_MAX_COUNT - pendingImages.length
const picked = Array.from(files)
if (picked.length > room) toast.warning("Too many images", `An item can have ${ITEM_IMAGE_MAX_COUNT}; only the first ${room} will be added.`)
const accepted: { file: File; url: string }[] = []
for (const file of picked.slice(0, Math.max(room, 0))) {
const problem = itemImageProblem(file)
if (problem) toast.error("Image skipped", problem)
else accepted.push({ file, url: URL.createObjectURL(file) })
}
setPendingImages((prev) => [...prev, ...accepted])
}
function removePendingImage(url: string) {
URL.revokeObjectURL(url)
setPendingImages((prev) => prev.filter((p) => p.url !== url))
}
function closeCustomPeriod() {
setCustomPeriodOpen(false)
setCustomPeriodAmount("")
setCustomPeriodUnit("months")
setCustomPeriodError(null)
}
/**
* Sets a warranty length for THIS item only. Nothing is saved to the Warranty Periods
* master — the item just stores the months (the server accepts any 1–600 while custom
* periods are on), so it isn't offered the next time the form opens.
*/
function addCustomWarrantyPeriod() {
const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths)
if (parsed.error !== undefined) {
setCustomPeriodError(parsed.error)
return
}
setItemOnlyWarrantyMonths(parsed.months)
setWarrantyPeriodMonths(parsed.months)
closeCustomPeriod()
}
function closeCustomVariant() {
setCustomOpen(false)
setCustomName("")
setCustomIsSize(false)
setCustomError(null)
}
/**
* Adds a variant dimension for THIS item only. Nothing is saved to the Item Variants master:
* items never reference an item type (the chosen values are baked into each generated
* item's name and SKU), so a form-local entry with a negative id is all the builder needs,
* and it is gone the next time the form opens.
*
* Ticked as a size variant it becomes a measurement dimension: values are entered as numbers
* in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own
* content size — the same path as a measurable Item Variant. Name defaults to "Size".
*/
function createCustomVariant() {
const name = customName.trim() || (customIsSize ? "Size" : "")
const invalid = validateItemTypeName(name).name
if (invalid) {
setCustomError(invalid)
return
}
if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) {
setCustomError(`"${name}" already exists — tick it above instead.`)
return
}
if (customIsSize && !measurementUnit) {
setCustomError("Pick a Measurement unit at the top first — size values are entered in it.")
return
}
// Only one dimension may supply the content size, or a variant would have two.
if (customIsSize && measurableCheckedCount > 0) {
setCustomError("Only one size variant can be used at a time — untick the other one first.")
return
}
const local: ItemType = {
itemTypeId: -Date.now(),
name,
isMeasurable: customIsSize,
status: "Active",
createdAt: new Date().toISOString(),
updatedAt: null,
}
setItemTypes((prev) => [...(prev ?? []), local])
setCheckedItemTypeIds((prev) => [...prev, local.itemTypeId])
closeCustomVariant()
}
/**
* Add is the real validation gate for a measurement — rejecting here means a half or
* duplicate pair never reaches state, so nothing downstream has to defend against one.
@@ -255,7 +412,9 @@ export default function NewItemPage() {
}
const raw = (qtyByCategory[itemTypeId] ?? "").trim()
const unit = unitByCategory[itemTypeId] ?? null
// The form-level Measurement unit, when chosen, is fixed for every size; otherwise the
// per-dimension pick.
const unit = measurementUnit ?? unitByCategory[itemTypeId] ?? null
const qty = Number(raw)
if (!raw || Number.isNaN(qty) || qty <= 0) {
@@ -416,6 +575,7 @@ export default function NewItemPage() {
setSubmitting(true)
let created = 0
const createdIds: number[] = []
try {
const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial"
@@ -430,7 +590,7 @@ export default function NewItemPage() {
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
const { data: createdItem } = await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${productLabel} - ${variant.parts.map((p) => p.value.label).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
@@ -463,8 +623,24 @@ export default function NewItemPage() {
contentUnit: content?.unit ?? null,
})
created += 1
createdIds.push(createdItem.itemId)
}
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
// Images are best-effort: the items already exist, so a failed upload is reported
// (and can be retried from the item page) rather than undoing the creation.
if (config?.itemImagesEnabled && pendingImages.length > 0) {
let failed = 0
for (const itemId of createdIds) {
for (const { file } of pendingImages) {
await itemsApi.uploadImage(itemId, file).catch(() => {
failed += 1
})
}
}
if (failed > 0)
toast.warning("Some images did not upload", `${failed} upload${failed === 1 ? "" : "s"} failed — add them from the item page.`)
}
router.push("/dashboard/products")
} catch (err) {
// Each row is its own POST with no transaction, so a failure partway (e.g. a
@@ -489,7 +665,7 @@ export default function NewItemPage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item variants (FR-MD-01).</p>
</div>
</div>
@@ -502,7 +678,7 @@ export default function NewItemPage() {
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM —{" "}
<Link href="/dashboard/products/uoms" className="font-semibold underline">
<Link href="/dashboard/products/categories?tab=uom" className="font-semibold underline">
create one first
</Link>
.
@@ -631,6 +807,32 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
{/* The unit size variants are measured in. Picked once here; every size value added
below (custom size variant or a measurement Item Variant) starts in this unit. */}
<div className="flex flex-col gap-2">
<Label className="text-base">Measurement unit</Label>
<Select<MeasureUnit | "none">
value={measurementUnit ?? "none"}
onValueChange={(v) => setMeasurementUnit(v && v !== "none" ? v : null)}
items={[
{ label: "None", value: "none" as const },
...CONTENT_UNITS.map((u) => ({ label: UNIT_LABEL[u], value: u })),
]}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-base">None</SelectItem>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Used for size variants (e.g. 500 ml, 1 L).</p>
</div>
{/* Content size (FR-MD-02): how much one stocked pack holds. Optional — a screw
or a label has none. Litres/kilograms are normalised to ml/g by the server.
Hidden once a measurement dimension is in play, because that dimension then
@@ -822,19 +1024,116 @@ export default function NewItemPage() {
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
items={warrantySelectGroups.flatMap((g) => g.months).map((m) => ({
label: formatWarrantyPeriod(m),
value: m,
}))}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectTrigger className="h-11! w-60 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
{warrantySelectGroups.length === 1
? warrantySelectGroups[0].months.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{formatWarrantyPeriod(months)}
</SelectItem>
))
: warrantySelectGroups.map((group, i) => (
<Fragment key={group.label}>
{i > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>{group.label}</SelectLabel>
{group.months.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{formatWarrantyPeriod(months)}
</SelectItem>
))}
</SelectGroup>
</Fragment>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
{config?.customWarrantyPeriodsEnabled &&
(customPeriodOpen ? (
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
<div>
<Label className="text-sm">Custom warranty period</Label>
<p className="text-xs text-muted-foreground">
For this item only — it isn&apos;t added to Warranty Periods or offered on other items.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={1}
step={1}
autoFocus
value={customPeriodAmount}
onChange={(e) => {
setCustomPeriodAmount(e.target.value)
setCustomPeriodError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addCustomWarrantyPeriod()
} else if (e.key === "Escape") {
closeCustomPeriod()
}
}}
placeholder="e.g. 2"
aria-label="Warranty length"
aria-invalid={!!customPeriodError}
className="h-10 w-24 text-base"
/>
<Select<WarrantyInputUnit>
value={customPeriodUnit}
onValueChange={(v) => v && setCustomPeriodUnit(v)}
items={[
{ label: "Months", value: "months" },
{ label: "Years", value: "years" },
]}
>
<SelectTrigger className="h-10! w-28 text-base" aria-label="Unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="months" className="text-base">Months</SelectItem>
<SelectItem value="years" className="text-base">Years</SelectItem>
</SelectContent>
</Select>
<Button type="button" size="sm" onClick={addCustomWarrantyPeriod}>
Use
</Button>
<Button type="button" size="sm" variant="ghost" onClick={closeCustomPeriod}>
Cancel
</Button>
</div>
{customPeriodUnit === "years" &&
Number.isInteger(Number(customPeriodAmount)) &&
Number(customPeriodAmount) > 0 && (
<span className="text-xs text-muted-foreground">
= {Number(customPeriodAmount) * 12} months
</span>
)}
{customPeriodError && <p className="text-sm text-destructive">{customPeriodError}</p>}
</div>
) : (
<Button
type="button"
variant="link"
size="sm"
className="h-auto w-fit px-0"
onClick={() => setCustomPeriodOpen(true)}
>
<Plus className="size-3.5" />
Custom period
</Button>
))}
</div>
)}
</div>
@@ -842,14 +1141,68 @@ export default function NewItemPage() {
</div>
)}
{config?.itemImagesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">
Images <span className="text-sm font-normal text-muted-foreground">(optional)</span>
</h2>
<p className="text-sm text-muted-foreground">
Not required — you can skip this and add images later from the item page. JPG, PNG or WebP, up to 5 MB each and {ITEM_IMAGE_MAX_COUNT} per item. They are added to every
variant created below; the first image is the main one.
</p>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-5 lg:grid-cols-8">
{pendingImages.map((p, index) => (
<div key={p.url} className="relative aspect-square overflow-hidden rounded-lg border bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element -- local object-URL preview */}
<img src={p.url} alt={p.file.name} className="size-full object-cover" />
{index === 0 && (
<span className="absolute top-1.5 left-1.5 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium">Main</span>
)}
<button
type="button"
onClick={() => removePendingImage(p.url)}
aria-label={`Remove ${p.file.name}`}
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive"
>
<X className="size-3.5" />
</button>
</div>
))}
{pendingImages.length < ITEM_IMAGE_MAX_COUNT && (
<button
type="button"
onClick={() => imageInputRef.current?.click()}
className="flex aspect-square flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<ImagePlus className="size-6" />
<span className="text-xs font-medium">Add images</span>
</button>
)}
</div>
<input
ref={imageInputRef}
type="file"
accept={ITEM_IMAGE_ACCEPT}
multiple
className="hidden"
onChange={(e) => {
addPendingImages(e.target.files)
e.target.value = ""
}}
/>
</div>
)}
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<h2 className="text-lg font-semibold text-foreground">Item variants</h2>
<p className="text-sm text-muted-foreground">
Check the item types that apply, then add their values to generate a SKU per combination.
Check the item variants that apply, then add their values to generate a SKU per combination.
</p>
{/* Without this, the per-size content feature is invisible: nothing is flagged as a
measurement out of the box, so the builder silently falls back to one shared
@@ -858,7 +1211,7 @@ export default function NewItemPage() {
<p className="mt-2 text-sm text-muted-foreground">
Selling the same product in several sizes (500 ml, 1 L)?{" "}
<Link href="/dashboard/products/item-types" className="font-medium underline">
Mark that item type as a measurement
Mark that item variant as a measurement
</Link>{" "}
and each size will capture its own number and unit, giving every variant its own content size.
</p>
@@ -886,13 +1239,83 @@ export default function NewItemPage() {
/>
<span className="text-base font-medium">{t.name}</span>
{t.isMeasurable && (
<Badge variant="outline" className="text-xs">measurement</Badge>
<Badge variant="outline" className="text-xs">size</Badge>
)}
{t.itemTypeId < 0 && (
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-xs text-primary">
this item only
</Badge>
)}
</label>
)
})}
{config?.customVariantsEnabled && !customOpen && (
<Button type="button" variant="outline" onClick={() => setCustomOpen(true)}>
<Plus className="size-4" />
Custom variant
</Button>
)}
</div>
{config?.customVariantsEnabled && customOpen && (
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-4">
<div>
<Label htmlFor="custom-variant-name" className="text-base">Custom variant</Label>
<p className="text-sm text-muted-foreground">
Used for this item only — it isn&apos;t added to Item Variants or offered on other items. Tick
Size variant to enter sizes in the Measurement unit above (500 ml, 1 L), each with its own
content amount.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
id="custom-variant-name"
autoFocus
value={customName}
onChange={(e) => {
setCustomName(e.target.value)
setCustomError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
createCustomVariant()
} else if (e.key === "Escape") {
closeCustomVariant()
}
}}
placeholder={customIsSize ? "Size" : "e.g. Colour"}
aria-invalid={!!customError}
className="h-11 text-base sm:max-w-64"
/>
<label
className={cn("flex items-center gap-2 text-sm", !measurementUnit && "text-muted-foreground")}
title={measurementUnit ? undefined : "Pick a Measurement unit at the top first"}
>
<Checkbox
checked={customIsSize}
disabled={!measurementUnit}
onCheckedChange={(c) => {
setCustomIsSize(c === true)
setCustomError(null)
}}
/>
{measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"}
</label>
<div className="flex gap-2 sm:ml-auto">
<Button type="button" variant="ghost" onClick={closeCustomVariant}>
Cancel
</Button>
<Button type="button" onClick={createCustomVariant}>
Add
</Button>
</div>
</div>
{customError && <p className="text-sm text-destructive">{customError}</p>}
</div>
)}
<FieldError
errors={[
errors.variants ? { message: errors.variants } : undefined,
@@ -930,22 +1353,32 @@ export default function NewItemPage() {
aria-invalid={!!valueErrors[t.itemTypeId]}
className="h-11 text-base"
/>
<Select<MeasureUnit | null>
value={unitByCategory[t.itemTypeId] ?? null}
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
{measurementUnit ? (
// Fixed by the form-level Measurement unit — every size is in that unit.
<div
className="flex h-11 w-24 shrink-0 items-center justify-center rounded-md border bg-muted text-base text-muted-foreground"
title="Set by Measurement unit above"
>
{UNIT_LABEL[measurementUnit]}
</div>
) : (
<Select<MeasureUnit | null>
value={unitByCategory[t.itemTypeId] ?? null}
onValueChange={(v) => setUnitByCategory((prev) => ({ ...prev, [t.itemTypeId]: v }))}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-11! w-24 shrink-0 text-base">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{CONTENT_UNITS.map((u) => (
<SelectItem key={u} value={u} className="text-base">
{UNIT_LABEL[u]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
<Plus className="size-4" />
Add
@@ -10,15 +10,14 @@ import {
ArrowUpDown,
Download,
Eye,
ListFilter,
ListTree,
Package,
Pencil,
Plus,
Ruler,
Search,
} from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { ItemStockFilter, itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config"
@@ -27,13 +26,13 @@ import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, TrackingMode } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ColumnsMenu } from "@/components/ui/columns-menu"
import { Input } from "@/components/ui/input"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
@@ -45,12 +44,23 @@ type SortOrder = "asc" | "desc"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
const COLUMN_OPTIONS = [
{ key: "categoryBrand", label: "Category / Brand" },
{ key: "stockValue", label: "Stock Value" },
{ key: "lastPurchase", label: "Last Purchase" },
{ key: "sales30d", label: "Sales (30d)" },
{ key: "status", label: "Status" },
const STOCK_OPTIONS: { label: string; value: ItemStockFilter | "All" }[] = [
{ label: "Any stock level", value: "All" },
{ label: "In stock", value: "InStock" },
{ label: "Out of stock", value: "OutOfStock" },
{ label: "Low stock", value: "LowStock" },
]
const TRACKING_OPTIONS: { label: string; value: TrackingMode | "All" }[] = [
{ label: "Any tracking", value: "All" },
{ label: "No tracking", value: "None" },
{ label: "Batch", value: "Batch" },
{ label: "Serial", value: "Serial" },
]
const NATURE_OPTIONS: { label: string; value: StockNature | "All" }[] = [
{ label: "Any stock nature", value: "All" },
{ label: "Stocked", value: "Stocked" },
{ label: "Non-stocked", value: "NonStocked" },
{ label: "Service", value: "Service" },
]
function formatMoney(n: number) {
@@ -70,6 +80,40 @@ function SortableHeader({ label, active, order, onClick }: { label: string; acti
)
}
function FilterSelect<T extends string | number>({
label,
value,
onChange,
options,
disabled,
placeholder,
}: {
label: string
value: T
onChange: (v: T) => void
options: { label: string; value: T }[]
disabled?: boolean
placeholder?: string
}) {
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<Select<T> value={value} onValueChange={(v) => v !== null && onChange(v)} items={options} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={String(o.value)} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)
}
export default function ItemsPage() {
const router = useRouter()
const isMobile = useIsMobile()
@@ -84,6 +128,12 @@ export default function ItemsPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All")
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | "All">("All")
const [brandId, setBrandId] = useState<number | "All">("All")
const [stock, setStock] = useState<ItemStockFilter | "All">("All")
const [trackingMode, setTrackingMode] = useState<TrackingMode | "All">("All")
const [stockNature, setStockNature] = useState<StockNature | "All">("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loadingMore, setLoadingMore] = useState(false)
@@ -91,7 +141,6 @@ export default function ItemsPage() {
const [selected, setSelected] = useState<Set<number>>(new Set())
const [sortKey, setSortKey] = useState<SortKey>("sku")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -100,7 +149,7 @@ export default function ItemsPage() {
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
// pages) — restarts at page 1. Reset during render rather than in an effect.
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
const resetKey = `${query}|${status}|${categoryId}|${subCategoryId}|${brandId}|${stock}|${trackingMode}|${stockNature}|${isMobile}`
const [prevResetKey, setPrevResetKey] = useState(resetKey)
if (prevResetKey !== resetKey) {
setPrevResetKey(resetKey)
@@ -118,6 +167,11 @@ export default function ItemsPage() {
q: query || undefined,
status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId,
subCategoryId: subCategoryId === "All" ? undefined : subCategoryId,
brandId: brandId === "All" ? undefined : brandId,
stock: stock === "All" ? undefined : stock,
trackingMode: trackingMode === "All" ? undefined : trackingMode,
stockNature: stockNature === "All" ? undefined : stockNature,
})
.then((res) => {
if (cancelled) return
@@ -135,7 +189,23 @@ export default function ItemsPage() {
return () => {
cancelled = true
}
}, [page, pageSize, query, status, categoryId, isMobile])
}, [page, pageSize, query, status, categoryId, subCategoryId, brandId, stock, trackingMode, stockNature, isMobile])
// Subcategories only make sense under a chosen category.
const subcategoriesOn = !!config?.subcategoriesEnabled && categoryId !== "All"
useEffect(() => {
if (!subcategoriesOn) return
let cancelled = false
categoriesApi
.listSubCategories(categoryId, { pageSize: 200 })
.then((res) => {
if (!cancelled) setSubCategories(res.items)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [subcategoriesOn, categoryId])
function showMore() {
setLoadingMore(true)
@@ -156,11 +226,21 @@ export default function ItemsPage() {
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
const advancedCount = [subCategoryId, brandId, stock, trackingMode, stockNature].filter((v) => v !== "All").length
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || advancedCount > 0
const showCol = (key: string) => visibleColumns[key] ?? true
function toggleColumn(key: string, next: boolean) {
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
function changeCategory(next: number | "All") {
setCategoryId(next)
setSubCategoryId("All")
setSubCategories([])
}
function clearAdvanced() {
setSubCategoryId("All")
setBrandId("All")
setStock("All")
setTrackingMode("All")
setStockNature("All")
}
function toggleSort(key: SortKey) {
@@ -236,14 +316,14 @@ export default function ItemsPage() {
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Ruler className="size-5" />
UOMs
</Link>
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<ListTree className="size-5" />
Categories
Category/Brand/UOM
</Link>
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-5" />
Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Item
@@ -251,50 +331,85 @@ export default function ItemsPage() {
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search SKU or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search items"
/>
{/* Same filter bar as the Sales list, but search on the left and every filter
(category, status, "Advanced" popover) grouped on the right. */}
<FilterBar
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Search SKU or name…"
searchAriaLabel="Search items"
advancedActive={advancedCount > 0}
onClearAdvanced={clearAdvanced}
advanced={
<>
<FilterSelect label="Stock level" value={stock} onChange={setStock} options={STOCK_OPTIONS} />
{config?.brandsEnabled && (
<FilterSelect
label="Brand"
value={brandId}
onChange={setBrandId}
options={[{ label: "All brands", value: "All" as const }, ...brands.map((b) => ({ label: b.name, value: b.brandId }))]}
/>
)}
{config?.subcategoriesEnabled && (
<FilterSelect
label="Subcategory"
value={subCategoryId}
onChange={setSubCategoryId}
disabled={categoryId === "All"}
placeholder={categoryId === "All" ? "Pick a category first" : undefined}
options={[
{ label: "All subcategories", value: "All" as const },
...subCategories.map((s) => ({ label: s.name, value: s.subCategoryId })),
]}
/>
)}
<FilterSelect label="Tracking" value={trackingMode} onChange={setTrackingMode} options={TRACKING_OPTIONS} />
<FilterSelect label="Stock nature" value={stockNature} onChange={setStockNature} options={NATURE_OPTIONS} />
</>
}
>
<div className="flex flex-wrap items-center gap-2">
<Select<number | "All">
value={categoryId}
onValueChange={(v) => changeCategory(v ?? "All")}
items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]}
>
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Category">
<ListTree className="size-4 text-muted-foreground" />
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select<StatusFilter>
value={status}
onValueChange={(v) => setStatus(v ?? "All")}
items={[
{ label: "All statuses", value: "All" as const },
{ label: "Active", value: "Active" as const },
{ label: "Inactive", value: "Inactive" as const },
]}
>
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
<ListFilter className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
<Select<number | "All">
value={categoryId}
onValueChange={(v) => setCategoryId(v ?? "All")}
items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
<ColumnsMenu columns={COLUMN_OPTIONS} visible={visibleColumns} onToggle={toggleColumn} />
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-4" />
Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
</div>
</FilterBar>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
@@ -355,61 +470,51 @@ export default function ItemsPage() {
<p className="text-sm text-muted-foreground">{item.sku}</p>
</div>
</div>
{showCol("status") && (
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
)}
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</div>
{showCol("categoryBrand") && (
<p className="text-sm text-muted-foreground">
{categoryName(item.categoryId)}
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
</p>
)}
<p className="text-sm text-muted-foreground">
{categoryName(item.categoryId)}
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{showCol("stockValue") && (
<div>
<p className="text-muted-foreground">Stock Value</p>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</div>
)}
{showCol("sales30d") && (
<div>
<p className="text-muted-foreground">Sales (30d)</p>
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
</div>
)}
{showCol("lastPurchase") && (
<div>
<p className="text-muted-foreground">Last Purchase</p>
{item.lastPurchaseDate ? (
<>
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
</>
) : (
<p className="text-foreground">—</p>
<div>
<p className="text-muted-foreground">Stock Value</p>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{item.stockQty.toLocaleString()} on hand</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
)}
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
</div>
<div>
<p className="text-muted-foreground">Sales (30d)</p>
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Last Purchase</p>
{item.lastPurchaseDate ? (
<>
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
</>
) : (
<p className="text-foreground">—</p>
)}
</div>
</div>
<Link
@@ -440,11 +545,11 @@ export default function ItemsPage() {
</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
<TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -474,52 +579,44 @@ export default function ItemsPage() {
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
{showCol("categoryBrand") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
)}
{showCol("stockValue") && (
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</TableCell>
)}
{showCol("lastPurchase") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.lastPurchaseDate ? (
<>
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
</>
) : (
"—"
<TableCell className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{item.stockQty.toLocaleString()} on hand</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</TableCell>
)}
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
{showCol("status") && (
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
)}
</div>
<p className="text-xs text-muted-foreground">{formatMoney(item.stockValue)}</p>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.lastPurchaseDate ? (
<>
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
</>
) : (
"—"
)}
</TableCell>
<TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Link
@@ -1,11 +1,12 @@
"use client"
import { useEffect, useState } from "react"
import { ReactNode, useEffect, useState } from "react"
import Link from "next/link"
import { Package } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { ProductConfig } from "@/types/master-data"
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
@@ -37,18 +38,10 @@ export default function ProductSettingsPage() {
useEffect(load, [])
async function toggle(flag: keyof Pick<
ProductConfig,
| "subcategoriesEnabled"
| "brandsEnabled"
| "itemTypesEnabled"
| "sellingPricesEnabled"
| "serialNumbersEnabled"
| "batchListsEnabled"
| "warrantyPeriodsEnabled"
>, next: boolean) {
if (!config) return
setSaving(flag)
/** PUT is a full replacement, so every save sends the whole current config plus `patch`. */
async function save(key: keyof ProductConfig, patch: Partial<UpdateProductConfigRequest>, message: string): Promise<boolean> {
if (!config) return false
setSaving(key)
try {
const res = await productConfigApi.update(
{
@@ -59,21 +52,30 @@ export default function ProductSettingsPage() {
serialNumbersEnabled: config.serialNumbersEnabled,
batchListsEnabled: config.batchListsEnabled,
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
[flag]: next,
customVariantsEnabled: config.customVariantsEnabled,
itemImagesEnabled: config.itemImagesEnabled,
customWarrantyPeriodsEnabled: config.customWarrantyPeriodsEnabled,
...patch,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
toast.success("Configuration saved", message)
return true
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load()
return false
} finally {
setSaving(null)
}
}
function toggle(flag: FlagKey, next: boolean) {
return save(flag, { [flag]: next }, `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -102,10 +104,42 @@ export default function ProductSettingsPage() {
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
<ToggleRow
label="Product variants"
description="Controls the variant builder on item create. Off hides the item-type section."
description="Controls the variant builder on item create. Off hides the item-variants section."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
onChange={(v) =>
v
? toggle("itemTypesEnabled", true)
: // Custom variants need this on, so switching it off turns that off too.
save(
"itemTypesEnabled",
{ itemTypesEnabled: false, customVariantsEnabled: false },
config.customVariantsEnabled
? "Product variants and custom variants disabled."
: "Product variants disabled.",
)
}
/>
<ToggleRow
label="Allow custom variants"
description={
config.itemTypesEnabled
? "Lets users create a new item variant (e.g. Flavour) right from the item create screen, without visiting Item Variants first."
: "Needs Product variants switched on — the custom variant option lives in the item-variants section."
}
checked={config.customVariantsEnabled}
busy={saving === "customVariantsEnabled"}
disabled={!config.itemTypesEnabled}
onChange={(v) => toggle("customVariantsEnabled", v)}
/>
<ToggleRow
label="Item images"
description="Adds an images area to item create and the item page (JPG, PNG or WebP, up to 10 per item). Off hides it; images already added stay visible."
checked={config.itemImagesEnabled}
busy={saving === "itemImagesEnabled"}
onChange={(v) => toggle("itemImagesEnabled", v)}
/>
<ToggleRow
@@ -137,7 +171,39 @@ export default function ProductSettingsPage() {
description="Allows warranty-tracked items to pick a coverage period."
checked={config.warrantyPeriodsEnabled}
busy={saving === "warrantyPeriodsEnabled"}
onChange={(v) => toggle("warrantyPeriodsEnabled", v)}
onChange={(v) =>
v
? toggle("warrantyPeriodsEnabled", true)
: // Custom warranty periods need this on, so switching it off turns that off too.
save(
"warrantyPeriodsEnabled",
{ warrantyPeriodsEnabled: false, customWarrantyPeriodsEnabled: false },
config.customWarrantyPeriodsEnabled
? "Warranty periods and custom warranty periods disabled."
: "Warranty periods disabled.",
)
}
/>
<ToggleRow
label="Custom warranty periods"
description={
config.warrantyPeriodsEnabled ? (
<>
Offers the periods from{" "}
<Link href="/dashboard/products/warranty-periods" className="font-medium underline">
Products → Warranty Periods
</Link>{" "}
alongside the default 3, 6, 12 and 18 months.
</>
) : (
"Needs Warranty periods switched on."
)
}
checked={config.customWarrantyPeriodsEnabled}
busy={saving === "customWarrantyPeriodsEnabled"}
disabled={!config.warrantyPeriodsEnabled}
onChange={(v) => toggle("customWarrantyPeriodsEnabled", v)}
/>
<ToggleRow
@@ -177,21 +243,40 @@ const LABELS: Record<keyof ProductConfig, string> = {
serialNumbersEnabled: "Serial numbers",
batchListsEnabled: "Batch lists",
warrantyPeriodsEnabled: "Warranty periods",
customVariantsEnabled: "Custom variants",
itemImagesEnabled: "Item images",
customWarrantyPeriodsEnabled: "Custom warranty periods",
updatedAt: "Updated at",
updatedBy: "Updated by",
}
type FlagKey = keyof Pick<
ProductConfig,
| "subcategoriesEnabled"
| "brandsEnabled"
| "itemTypesEnabled"
| "sellingPricesEnabled"
| "serialNumbersEnabled"
| "batchListsEnabled"
| "warrantyPeriodsEnabled"
| "customVariantsEnabled"
| "itemImagesEnabled"
| "customWarrantyPeriodsEnabled"
>
function ToggleRow({
label,
description,
checked,
busy,
disabled,
onChange,
}: {
label: string
description: string
description: ReactNode
checked: boolean
busy: boolean
disabled?: boolean
onChange: (next: boolean) => void
}) {
return (
@@ -199,7 +284,7 @@ function ToggleRow({
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={busy}
disabled={busy || disabled}
aria-label={label}
className="mt-1 shrink-0"
/>
@@ -3,13 +3,13 @@
import { useEffect } from "react"
import { useRouter } from "next/navigation"
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
// to keep old links/bookmarks working.
// UOM now lives as a tab on the merged Category/Brand/UOM page — this route stays
// only to keep old links/bookmarks working.
export default function UomsRedirectPage() {
const router = useRouter()
useEffect(() => {
router.replace("/dashboard/products/brands?tab=uom")
router.replace("/dashboard/products/categories?tab=uom")
}, [router])
return null
@@ -0,0 +1,290 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Ban, CheckCircle2, Plus, ShieldCheck } from "lucide-react"
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit } from "@/lib/warranty"
import { WARRANTY_PERIOD_MONTHS_OPTIONS, WarrantyPeriod } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/**
* Warranty Periods — custom coverage lengths offered to items on top of the fixed defaults
* (3/6/12/18 months), while Product Configuration → "Custom warranty periods" is on.
*
* Entered in months or years, always stored as months. Items store the months themselves,
* so a length can't be edited (add a new one instead) and deactivating one only stops it
* being offered — items already using it keep it.
*/
export default function WarrantyPeriodsPage() {
const [periods, setPeriods] = useState<WarrantyPeriod[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [customEnabled, setCustomEnabled] = useState<boolean | null>(null)
const [open, setOpen] = useState(false)
const [amount, setAmount] = useState("")
const [unit, setUnit] = useState<WarrantyInputUnit>("months")
const [formError, setFormError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
warrantyPeriodsApi
.list()
.then(setPeriods)
.catch((err) => setError(errorMessage(err)))
}
// Initial fetch sets state only in callbacks (load() also clears the error, for reloads).
useEffect(() => {
warrantyPeriodsApi
.list()
.then(setPeriods)
.catch((err) => setError(errorMessage(err)))
productConfig()
.then((cfg) => setCustomEnabled(cfg.warrantyPeriodsEnabled && cfg.customWarrantyPeriodsEnabled))
.catch(() => setCustomEnabled(null))
}, [])
function openCreateDialog() {
setAmount("")
setUnit("months")
setFormError(null)
setOpen(true)
}
async function handleSubmit() {
// Only an ACTIVE duplicate is an error — re-adding an inactive length reactivates it.
const activeMonths = (periods ?? []).filter((p) => p.status === "Active").map((p) => p.months)
const parsed = parseWarrantyInput(amount, unit, activeMonths)
if (parsed.error !== undefined) {
setFormError(parsed.error)
return
}
setSubmitting(true)
try {
await warrantyPeriodsApi.create(parsed.months)
toast.success("Warranty period added", formatWarrantyPeriod(parsed.months))
setOpen(false)
load()
} catch (err) {
setFormError(errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(period: WarrantyPeriod) {
const next = period.status === "Active" ? "Inactive" : "Active"
setTogglingId(period.warrantyPeriodId)
try {
await warrantyPeriodsApi.updateStatus(period.warrantyPeriodId, next)
toast.success(
next === "Inactive" ? "Warranty period deactivated" : "Warranty period activated",
formatWarrantyPeriod(period.months),
)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
const years = Number(amount)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Warranty Periods</h1>
<p className="text-base text-muted-foreground">
Your own warranty lengths, offered on items alongside the defaults. Enter them in months or years — they are
saved as months.
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg" onClick={openCreateDialog}>
<Plus className="size-5" />
Add Warranty Period
</Button>
}
/>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New warranty period</DialogTitle>
<DialogDescription>How long the warranty lasts.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!formError}>
<FieldLabel htmlFor="warranty-period-amount">Length</FieldLabel>
<div className="flex gap-2">
<Input
id="warranty-period-amount"
type="number"
inputMode="numeric"
min={1}
step={1}
autoFocus
value={amount}
onChange={(e) => {
setAmount(e.target.value)
setFormError(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSubmit()
}
}}
placeholder="e.g. 2"
aria-invalid={!!formError}
/>
<Select<WarrantyInputUnit>
value={unit}
onValueChange={(v) => v && setUnit(v)}
items={[
{ label: "Months", value: "months" },
{ label: "Years", value: "years" },
]}
>
<SelectTrigger className="w-32 shrink-0" aria-label="Unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="months">Months</SelectItem>
<SelectItem value="years">Years</SelectItem>
</SelectContent>
</Select>
</div>
{unit === "years" && Number.isInteger(years) && years > 0 && (
<span className="text-sm text-muted-foreground">= {years * 12} months</span>
)}
<FieldError errors={[formError ? { message: formError } : 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={handleSubmit} disabled={submitting}>
{submitting ? "Adding…" : "Add"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{customEnabled === false && (
<div className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
Custom warranty periods are switched off, so items only offer the defaults. Turn on{" "}
<span className="font-medium">Custom warranty periods</span> in{" "}
<Link href="/dashboard/products/settings" className="font-medium underline">
Product Configuration
</Link>{" "}
to use these.
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Default periods (always offered):</span>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((m) => (
<Badge key={m} variant="outline" className="text-muted-foreground">
{formatWarrantyPeriod(m)}
</Badge>
))}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && periods === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && periods !== null && periods.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ShieldCheck className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No custom warranty periods yet.</p>
</div>
)}
{!error && periods !== null && periods.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Period</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{periods.map((p) => (
<TableRow key={p.warrantyPeriodId}>
<TableCell className="px-3 py-3.5">{p.warrantyPeriodId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{formatWarrantyPeriod(p.months)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={p.status === "Active" ? "default" : "secondary"}>{p.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(p.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={p.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}`}
disabled={togglingId === p.warrantyPeriodId}
/>
}
>
{p.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={p.status === "Active" ? "destructive" : "success"}
title={`${p.status === "Active" ? "Deactivate" : "Activate"} ${formatWarrantyPeriod(p.months)}?`}
description={
p.status === "Active"
? "It stops being offered on items. Items already using it keep their warranty period."
: "It is offered on items again."
}
confirmLabel={p.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(p)}
/>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -6,7 +6,7 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Combobox } from "@/components/ui/combobox"
import { SuggestInput } from "@/components/ui/suggest-input"
import {
Dialog,
DialogContent,
@@ -141,13 +141,8 @@ export default function NewFreeIssuePage() {
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setLines([
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
warehouseId: whRes.items[0]?.warehouseId ?? 0,
},
])
// No item pre-selected — the user searches for one.
setLines([{ ...blankLine("line-1"), warehouseId: whRes.items[0]?.warehouseId ?? 0 }])
await refreshRows()
})
.catch((err) => setError(errorMessage(err)))
@@ -167,7 +162,7 @@ export default function NewFreeIssuePage() {
}
const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
[items]
)
@@ -369,12 +364,29 @@ export default function NewFreeIssuePage() {
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
placeholder="Search item…"
/>
{/* No item until one is searched for and picked — nothing pre-selected, no dropdown list. */}
{line.itemId ? (
<div className="flex h-9 items-center justify-between gap-2 rounded-lg border border-input px-2.5 text-sm">
<span className="truncate font-medium">
{itemOptions.find((o) => o.value === String(line.itemId))?.label ?? `Item #${line.itemId}`}
</span>
<button
type="button"
onClick={() => (editingRowId ? selectEditingItem(line.key, 0) : selectItem(line.key, 0))}
className="shrink-0 text-xs font-medium text-primary hover:underline"
>
Change
</button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => (editingRowId ? selectEditingItem(line.key, Number(v)) : selectItem(line.key, Number(v)))}
placeholder="Search item by SKU or name…"
emptyText="No items match."
aria-label="Item"
/>
)}
</Field>
<Field>
<FieldLabel>UOM</FieldLabel>
@@ -3,7 +3,7 @@
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, Undo2, X } from "lucide-react"
import { ArrowLeft, ExternalLink, Minus, Printer, Save, Send, Undo2, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -13,6 +13,7 @@ import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { salesConfigApi } from "@/lib/api/sales-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
@@ -24,6 +25,7 @@ import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, Sales
import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { SuggestInput } from "@/components/ui/suggest-input"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
@@ -85,6 +87,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
// Sales Configuration "Allow free items": off hides the draft editor's Free column.
const [allowFreeItems, setAllowFreeItems] = useState(true)
// "Allow discounts": off saves the draft's lines without a line discount (the server rejects one).
const [allowDiscounts, setAllowDiscounts] = useState(true)
// A saved default sales warehouse (or "Allow warehouse selection" off) replaces the draft
// editor's Warehouse picker with a locked field — the server keeps the invoice on it regardless.
const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true)
const [defaultWarehouseId, setDefaultWarehouseId] = useState<number | null>(null)
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
@@ -100,8 +110,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
salesApi.getInvoice(invoiceId),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
salesConfigApi.get().then((r) => r.data).catch(() => null),
])
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes]) => {
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes, config]) => {
setAllowFreeItems(config?.allowFreeItems ?? true)
setAllowDiscounts(config?.allowDiscounts ?? true)
setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true))
setDefaultWarehouseId(config?.defaultWarehouseId ?? null)
setCustomers(customerRes.items)
setItems(itemRes.items)
setUoms(uomRes.items)
@@ -170,6 +185,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const isDraft = invoice?.status === "Draft"
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
// With selection off, saving moves the draft onto the default sales warehouse (server-forced),
// so that's the one the editor shows.
const pinnedWarehouse = useMemo(() => warehouses.find((w) => w.warehouseId === defaultWarehouseId), [warehouses, defaultWarehouseId])
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
const canPost = invoice?.status === "Draft" && (postingCheck?.canPost ?? true) && !busy && !saving
@@ -182,11 +200,34 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextWarehouseId ?? line.warehouseId })))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, {
itemId,
unitPrice: getSuggestedUnitPrice(items, itemId),
// Type-ahead "Add item" options — nothing is listed until the user types.
const itemOptions = useMemo(
() =>
items.map((i) => {
const price = getSuggestedUnitPrice(items, i.itemId)
return { value: String(i.itemId), label: `${i.sku} — ${i.name}`, hint: price !== null ? money.format(price) : undefined }
}),
[items],
)
function itemLabel(itemId: number) {
const item = items.find((i) => i.itemId === itemId)
return item ? `${item.sku} — ${item.name}` : `Item #${itemId}`
}
// Picking an item already on the invoice bumps its qty instead of adding a duplicate line.
function addItem(itemId: number) {
setLines((prev) => {
const idx = prev.findIndex((l) => l.itemId === itemId)
if (idx >= 0) {
const next = [...prev]
next[idx] = { ...next[idx], qty: Number(next[idx].qty || 0) + 1 }
return next
}
return [
...prev,
{ ...blankLine(`line-${Date.now()}`), itemId, warehouseId: warehouseId ?? 0, unitPrice: getSuggestedUnitPrice(items, itemId) },
]
})
}
@@ -195,10 +236,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
@@ -224,15 +261,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
discountAmount: allowDiscounts ? Number(line.discountAmount) : 0,
discountValue: allowDiscounts ? Number(line.discountValue) : 0,
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null,
})),
},
@@ -429,13 +466,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
) : null}
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
{/* canPost with issues = Sales Configuration allows selling beyond stock: shown as a
warning, posting still goes ahead and drives on-hand negative. */}
{invoice.status === "Draft" && postingCheck && postingCheck.issues.length > 0 ? (
<div className="border-t pt-5">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
<div className="mt-1 text-sm">
{postingCheck.canPost
? "Selling beyond stock is allowed — posting will take these items' stock below zero."
: "The invoice cannot be posted until every line has enough available stock in the selected warehouse."}
</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
@@ -486,10 +529,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{isDraft ? (
<>
<SuggestInput
options={itemOptions}
onSelect={(v) => addItem(Number(v))}
placeholder="Add item — type SKU or name…"
emptyText="No items match."
className="mb-4 w-full sm:max-w-md"
aria-label="Add item"
/>
<div className="mb-4 flex flex-wrap gap-2">
<button type="button" onClick={addLine} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted">
<Plus className="size-4" /> Add line
</button>
<button type="button" onClick={save} disabled={saving} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
<Save className="size-4" />
{saving ? "Saving..." : "Save invoice"}
@@ -520,7 +568,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<th className="px-4 py-2 text-left font-medium">Item</th>
<th className="px-4 py-2 text-left font-medium">UOM</th>
<th className="px-4 py-2 text-right font-medium">Qty</th>
<th className="px-4 py-2 text-right font-medium">Free</th>
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
<th className="px-4 py-2 text-right font-medium">Unit price</th>
<th className="px-4 py-2 text-right font-medium">Remove</th>
</tr>
@@ -529,18 +577,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{lines.map((line) => (
<tr key={line.key} className="border-t border-border align-middle">
<td className="px-4 py-3 min-w-64">
<select
value={line.itemId ? String(line.itemId) : ""}
onChange={(e) => selectItem(line.key, Number(e.target.value))}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select item</option>
{items.map((i) => (
<option key={i.itemId} value={String(i.itemId)}>
{i.sku} - {i.name}
</option>
))}
</select>
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
</td>
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
@@ -555,16 +592,18 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
{allowFreeItems && (
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
/>
</td>
)}
<td className="px-4 py-3 w-32">
<input
type="number"
@@ -614,18 +653,25 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
{allowWarehouseSelection ? (
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
) : (
// Pinned to the default sales warehouse — shown, not editable.
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Invoice type</Label>
@@ -634,8 +680,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="B2B">B2B</option>
<option value="B2C">B2C</option>
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
<option value="Cash">Cash</option>
<option value="Credit">Credit</option>
</select>
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { Plus, Trash2 } from "lucide-react"
import { Trash2 } from "lucide-react"
import { useAuth } from "@/components/auth/AuthProvider"
import { ScanInput } from "@/components/scanning/ScanInput"
@@ -12,7 +12,7 @@ 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"
import { Combobox } from "@/components/ui/combobox"
import { SuggestInput } from "@/components/ui/suggest-input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
@@ -21,6 +21,7 @@ import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { ApiError } from "@/lib/api-client"
import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
@@ -30,7 +31,13 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { CreateSalesInvoiceLineRequest, CreateSalesInvoiceRequest, SalesInvoiceType } from "@/types/sales"
import {
CreateSalesInvoiceLineRequest,
CreateSalesInvoiceRequest,
SalesConfig,
SalesInvoiceType,
SalesStockAvailability,
} from "@/types/sales"
import { BundleSaleTemplateSummary } from "@/types/bundles"
import { toast } from "@/components/ui/toast"
@@ -119,14 +126,46 @@ function NewSalesInvoiceContent() {
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("Cash")
// Starts empty — lines are only ever added by picking an item/bundle in the "Add item" box
// (or scanning), never as blank rows with a dropdown to fill in.
const [lines, setLines] = useState<Line[]>([])
const [freeIssueSchemes, setFreeIssueSchemes] = useState<FreeIssueScheme[]>([])
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
// Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price.
const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({})
// Sales Configuration "Allow cash customer": when on, the customer dropdown offers the generic
// cash customer first and the sale starts on it; when off, a real customer must be picked (no
// default). The cash customer row itself never shows up among the regular customers.
const [salesConfig, setSalesConfig] = useState<SalesConfig | null>(null)
const cashCustomerId = salesConfig?.allowCashCustomer ? salesConfig.cashCustomerId : null
// "Allow free items": off hides the Free column and never auto-fills a free qty. A missing
// config keeps the pre-option behaviour (allowed).
const allowFreeItems = salesConfig?.allowFreeItems ?? true
// "Allow discounts": off hides the Disc % column and never sends a line discount.
const allowDiscounts = salesConfig?.allowDiscounts ?? true
// A saved default sales warehouse pins the sale to it (locked field, server-forced too); the
// picker only appears when none is saved and "Allow warehouse selection" is on.
const allowWarehouseSelection = !salesConfig?.defaultWarehouseId && (salesConfig?.allowWarehouseSelection ?? true)
function warehouseLabel(id: number | null): string | null {
const w = id === null ? undefined : warehouses.find((x) => x.warehouseId === id)
return w ? `${w.code} — ${w.name}` : null
}
const regularCustomers = useMemo(
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
[customers, salesConfig],
)
function selectCustomer(nextCustomerId: number | null) {
setCustomerId(nextCustomerId)
// A cash customer sale is a cash sale — preset the type (still changeable).
if (nextCustomerId !== null && nextCustomerId === cashCustomerId) setInvoiceType("Cash")
}
useEffect(() => {
Promise.all([
@@ -136,24 +175,25 @@ function NewSalesInvoiceContent() {
warehousesApi.list({ pageSize: 200 }),
salesApi.listFreeIssues({ pageSize: 20 }),
bundleApi.listTemplates({ pageSize: 200 }),
// Missing/failed config just means "no cash customer" — never block the form on it.
salesConfigApi.get().then((r) => r.data).catch(() => null),
])
.then(([cust, itemRes, uomRes, whRes, freeIssueRes, templateRes]) => {
.then(([cust, itemRes, uomRes, whRes, freeIssueRes, templateRes, config]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setBundleTemplates(templateRes.items)
const defaultWarehouseId = whRes.items[0]?.warehouseId ?? null
setCustomerId(cust.items[0]?.customerId ?? null)
setSalesConfig(config)
const defaultWarehouseId =
config?.defaultWarehouseId ?? (config && !config.allowWarehouseSelection ? null : (whRes.items[0]?.warehouseId ?? null))
if (config?.allowCashCustomer && config.cashCustomerId) {
setCustomerId(config.cashCustomerId)
setInvoiceType("Cash")
} else {
setCustomerId(null)
}
setWarehouseId(defaultWarehouseId)
setLines([
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
warehouseId: defaultWarehouseId ?? 0,
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
},
])
// Cancelled Free Issues don't represent a live deal — everything else (Draft/Posted)
// does. First match per item wins (listFreeIssues returns newest first).
setFreeIssueSchemes(
@@ -201,7 +241,9 @@ function NewSalesInvoiceContent() {
// Switching branch can strip the currently-picked warehouse out of the visible set —
// clear it (and every line's warehouse) rather than silently submitting a mismatched one.
// A pinned default sales warehouse is never cleared this way — there's no picker to re-pick with.
useEffect(() => {
if (!allowWarehouseSelection) return
if (warehouseId !== null && !visibleWarehouses.some((w) => w.warehouseId === warehouseId)) updateHeaderWarehouse(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleWarehouses])
@@ -214,20 +256,12 @@ function NewSalesInvoiceContent() {
// own qty (10 units -> 2 free, 25 units -> still 2, 30 units -> 4) — matches how the Free
// Issue slip itself frames the deal ("Buy {qty} Get {freeQty}"), not a per-unit ratio.
function suggestedFreeQty(itemId: number, qty: number): number {
if (!allowFreeItems) return 0
const scheme = freeIssueSchemeFor(itemId)
if (!scheme || !qty) return 0
return Math.floor(qty / scheme.qty) * scheme.freeQty
}
function selectItem(key: string, itemId: number) {
const qty = Number(lines.find((l) => l.key === key)?.qty ?? 0)
updateLine(key, {
itemId,
unitPrice: getSuggestedUnitPrice(items, itemId),
freeQty: suggestedFreeQty(itemId, qty),
})
}
function updateQty(key: string, itemId: number, qty: number) {
updateLine(key, { qty, freeQty: suggestedFreeQty(itemId, qty) })
}
@@ -236,46 +270,163 @@ function NewSalesInvoiceContent() {
// optionally its batch/serial), then either bumps an existing line for the same item+lot by one
// unit, or appends a new line for it — batchId/serialId ride along on the line for traceability
// (see the Line type note above re: submit() not yet sending them).
// Shared by the "Add item" suggestion box and barcode scans: bumps an existing line for the
// same item+lot by one unit, or appends a new line for it. Returns the affected line's key.
function addItem(
itemId: number,
lot: { batchId?: number | null; batchNo?: string | null; serialId?: number | null; serialNo?: string | null } = {},
): string {
const sameLine = (l: Line) =>
!l.bundleGroupId &&
l.itemId === itemId &&
(l.batchId ?? null) === (lot.batchId ?? null) &&
(l.serialId ?? null) === (lot.serialId ?? null)
const newKey = `line-${Date.now()}`
const lineKey = lines.find(sameLine)?.key ?? newKey
setLines((prev) => {
const idx = prev.findIndex(sameLine)
if (idx >= 0) {
const next = [...prev]
const nextQty = Number(next[idx].qty || 0) + 1
next[idx] = { ...next[idx], qty: nextQty, freeQty: suggestedFreeQty(itemId, nextQty) }
return next
}
return [
...prev,
{
...blankLine(newKey),
itemId,
warehouseId: warehouseId ?? 0,
qty: 1,
unitPrice: getSuggestedUnitPrice(items, itemId),
freeQty: suggestedFreeQty(itemId, 1),
batchId: lot.batchId ?? null,
batchNo: lot.batchNo ?? null,
serialId: lot.serialId ?? null,
serialNo: lot.serialNo ?? null,
},
]
})
fillFallbackPrice(itemId)
return lineKey
}
// Keyboard entry flow: picking from the list jumps the cursor to that line's Qty, then Enter
// walks Qty → Free → Unit price → Disc %, and Enter on Disc % returns to the search box. Each
// field's text is selected on arrival, so typing replaces it. Scans don't jump to Qty, so a
// scanner can keep firing codes into the search box. Both the mobile card and the desktop row
// render these inputs per line — always focus whichever one is actually visible.
const lineFieldOrder = ["qty", "free", "price", "disc"] as const
type LineField = (typeof lineFieldOrder)[number]
function focusVisible(selector: string): boolean {
const input = Array.from(document.querySelectorAll<HTMLInputElement>(selector)).find((el) => el.offsetParent !== null)
if (!input) return false
input.focus()
input.select()
return true
}
const [focusQtyKey, setFocusQtyKey] = useState<string | null>(null)
useEffect(() => {
if (focusQtyKey && focusVisible(`input[data-line-key="${focusQtyKey}"][data-nav="qty"]`)) setFocusQtyKey(null)
}, [focusQtyKey, lines])
function onLineFieldKeyDown(e: React.KeyboardEvent<HTMLInputElement>, lineKey: string, field: LineField) {
if (e.key !== "Enter") return
e.preventDefault()
const order: readonly LineField[] = lineFieldOrder.filter(
(f) => (f !== "free" || allowFreeItems) && (f !== "disc" || allowDiscounts),
)
const next = order[order.indexOf(field) + 1]
if (next) focusVisible(`input[data-line-key="${lineKey}"][data-nav="${next}"]`)
else focusVisible('input[aria-label="Add item"]')
}
const navProps = (lineKey: string, field: LineField) => ({
"data-line-key": lineKey,
"data-nav": field,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => onLineFieldKeyDown(e, lineKey, field),
})
// Items without a flat selling price get priced on save from the warehouse's GRN/FIFO average —
// fetch that same figure now so Unit price isn't left blank. Only fills lines still blank, so a
// price the user already typed is never overwritten. A 0 result (no stock history) stays blank.
async function fillFallbackPrice(itemId: number) {
if (!warehouseId || getSuggestedUnitPrice(items, itemId) !== null) return
const key = `${itemId}:${warehouseId}`
let price = resolvedPrices[key]
if (price === undefined) {
try {
price = (await salesApi.getSuggestedPrice(itemId, warehouseId)).unitPrice
} catch {
return
}
setResolvedPrices((prev) => ({ ...prev, [key]: price }))
}
if (!price) return
setLines((prev) =>
prev.map((l) =>
!l.bundleGroupId && l.itemId === itemId && l.warehouseId === warehouseId && l.unitPrice === null
? { ...l, unitPrice: price }
: l,
),
)
}
// "Allow selling beyond stock" off: each item's total qty (+ free) per warehouse — summed across
// lines, since a bundle and a loose line can draw on the same stock — must fit the issuable
// stock, else the line shows the available count and Create is blocked. Fetched once per
// item/warehouse pair; non-stock items are never short.
const [availability, setAvailability] = useState<Record<string, SalesStockAvailability>>({})
const checkStock = !!salesConfig && !salesConfig.allowOverStockSale
useEffect(() => {
if (!checkStock) return
const pending = new Set(
lines.filter((l) => l.itemId && l.warehouseId).map((l) => `${l.itemId}:${l.warehouseId}`),
)
pending.forEach((key) => {
if (availability[key]) return
const [itemId, whId] = key.split(":").map(Number)
salesApi
.getStockAvailability(itemId, whId)
.then((a) => setAvailability((prev) => ({ ...prev, [key]: a })))
.catch(() => {})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lines, checkStock])
const requestedByStockKey = useMemo(() => {
const totals: Record<string, number> = {}
for (const l of lines) {
if (!l.itemId || !l.warehouseId) continue
const key = `${l.itemId}:${l.warehouseId}`
totals[key] = (totals[key] ?? 0) + Number(l.qty || 0) + (allowFreeItems ? Number(l.freeQty || 0) : 0)
}
return totals
}, [lines, allowFreeItems])
/** Available count when this line's item is over-requested at its warehouse, else null. */
function stockShortage(line: Line): number | null {
if (!checkStock) return null
const key = `${line.itemId}:${line.warehouseId}`
const a = availability[key]
if (!a || !a.isStocked) return null
return (requestedByStockKey[key] ?? 0) > a.available ? Math.max(0, a.available) : null
}
function suggestedPriceFor(line: Line): number | null {
const flat = getSuggestedUnitPrice(items, line.itemId)
if (flat !== null) return flat
return resolvedPrices[`${line.itemId}:${line.warehouseId}`] || null
}
async function handleScan(code: string) {
setScanError(null)
try {
const result = await itemsApi.scan(code)
setLines((prev) => {
const idx = prev.findIndex(
(l) =>
!l.bundleGroupId &&
l.itemId === result.itemId &&
(l.batchId ?? null) === (result.batchId ?? null) &&
(l.serialId ?? null) === (result.serialId ?? null)
)
if (idx >= 0) {
const next = [...prev]
const nextQty = Number(next[idx].qty || 0) + 1
next[idx] = { ...next[idx], qty: nextQty, freeQty: suggestedFreeQty(result.itemId, nextQty) }
return next
}
// Fill the first still-blank row (item never picked) instead of appending below it —
// whether it's the lone starter line or one left empty further down the table.
const blankIdx = prev.findIndex((l) => l.itemId === 0 && !l.bundleGroupId)
const newLine: Line = {
...blankLine(blankIdx >= 0 ? prev[blankIdx].key : `line-${Date.now()}`),
itemId: result.itemId,
warehouseId: warehouseId ?? 0,
qty: 1,
unitPrice: getSuggestedUnitPrice(items, result.itemId),
freeQty: suggestedFreeQty(result.itemId, 1),
batchId: result.batchId,
batchNo: result.batchNo,
serialId: result.serialId,
serialNo: result.serialNo,
}
if (blankIdx >= 0) {
const next = [...prev]
next[blankIdx] = newLine
return next
}
return [...prev, newLine]
})
addItem(result.itemId, result)
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`)
@@ -285,10 +436,14 @@ function NewSalesInvoiceContent() {
}
}
// The per-line item search doubles as bundle search: item values are prefixed "item:" and
// bundle template values "bundle:" so one Combobox/handler can tell them apart on select.
// The "Add item" box doubles as bundle search: item values are prefixed "item:" and bundle
// template values "bundle:" so one handler can tell them apart on select.
const itemOptions = useMemo(
() => items.map((i) => ({ value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}` })),
() =>
items.map((i) => {
const price = getSuggestedUnitPrice(items, i.itemId)
return { value: `item:${i.itemId}`, label: `${i.sku} — ${i.name}`, hint: price !== null ? lkr.format(price) : undefined }
}),
[items],
)
@@ -302,16 +457,6 @@ function NewSalesInvoiceContent() {
[itemOptions, bundleTemplateOptions],
)
function addLine() {
setLines((prev) => [
...prev,
{
...blankLine(`line-${Date.now()}`),
warehouseId: warehouseId ?? 0,
},
])
}
// A bundle template isn't a stock item itself — it has no itemId of its own — so "adding" one
// expands it into its real component lines (each with its own itemId/qty/price) rather than a
// single pseudo-item row. That's what keeps stock and GL postings correct per component.
@@ -362,18 +507,14 @@ function NewSalesInvoiceContent() {
)
}
// Combined handler for each line's item/bundle search box — routes to the right action based
// on the "item:"/"bundle:" value prefix (see lineComboOptions above).
function selectLineOption(key: string, value: string | null) {
if (!value) return
// Handler for the "Add item" suggestion box — routes on the "item:"/"bundle:" value prefix
// (see lineComboOptions above).
function addFromSuggestion(value: string) {
const sep = value.indexOf(":")
const kind = value.slice(0, sep)
const id = Number(value.slice(sep + 1))
if (kind === "bundle") {
addBundle(id, key)
} else {
selectItem(key, id)
}
if (kind === "bundle") addBundle(id)
else setFocusQtyKey(addItem(id))
}
// Deep-linked from the Bundle Templates page's "Sell" button (?bundleTemplateId=...) — applies
@@ -386,15 +527,17 @@ function NewSalesInvoiceContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, bundleTemplateIdFromQuery])
function itemLabel(itemId: number) {
const item = items.find((i) => i.itemId === itemId)
return item ? `${item.sku} — ${item.name}` : `Item #${itemId}`
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
setLines((prev) => prev.filter((line) => line.key !== key))
}
function removeBundleGroup(groupId: string) {
setLines((prev) => {
const next = prev.filter((line) => line.bundleGroupId !== groupId)
return next.length === 0 ? [{ ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }] : next
})
setLines((prev) => prev.filter((line) => line.bundleGroupId !== groupId))
}
// Groups consecutive component lines added from the same bundle into one display row, so the
@@ -450,8 +593,14 @@ function NewSalesInvoiceContent() {
async function submit() {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
if (lines.length === 0) return setSubmitError("Add at least one item.")
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
const short = lines.filter((line) => stockShortage(line) !== null)
if (short.length > 0) {
const detail = [...new Set(short.map((l) => `${itemLabel(l.itemId)} (${stockShortage(l)} available)`))].join(", ")
return setSubmitError(`Quantity exceeds available stock: ${detail}. Reduce the quantity to continue.`)
}
const payload: CreateSalesInvoiceRequest = {
customerId,
@@ -462,15 +611,15 @@ function NewSalesInvoiceContent() {
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
freeQty: allowFreeItems ? Number(line.freeQty) : 0,
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
discountPct: allowDiscounts ? Number(line.discountPct) : 0,
discountAmount: allowDiscounts ? Number(line.discountAmount) : 0,
discountValue: allowDiscounts ? Number(line.discountValue) : 0,
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null,
})),
}
@@ -512,12 +661,17 @@ function NewSalesInvoiceContent() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => selectCustomer(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
{cashCustomerId !== null && (
<SelectItem value={String(cashCustomerId)} className="text-base font-medium">
{salesConfig?.cashCustomerName ?? "Cash Customer"}
</SelectItem>
)}
{regularCustomers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)} className="text-base">
{c.customerCode} — {c.displayName ?? c.name}
</SelectItem>
@@ -552,33 +706,25 @@ function NewSalesInvoiceContent() {
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Invoice type</Label>
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2B" className="text-base">B2B</SelectItem>
<SelectItem value="B2C" className="text-base">B2C</SelectItem>
<SelectItem value="Cash" className="text-base">Cash</SelectItem>
<SelectItem value="Credit" className="text-base">Credit</SelectItem>
</SelectContent>
</Select>
{allowWarehouseSelection ? (
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => updateHeaderWarehouse(v ? Number(v) : null)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
// Pinned to the default sales warehouse — shown, not editable (same as a locked branch).
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{warehouseLabel(warehouseId) ?? salesConfig?.defaultWarehouseName ?? "No default warehouse set"}
</div>
)}
</div>
</div>
@@ -587,21 +733,37 @@ function NewSalesInvoiceContent() {
label="Scan to add"
hint="Scan an item barcode, or a batch/serial lot barcode, to add or bump a line."
onScan={handleScan}
// The "Add item" box below gets the cursor instead — it also accepts scans.
autoFocus={false}
className="max-w-md"
/>
{scanError && <FieldError errors={[{ message: scanError }]} />}
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" onClick={addLine}>
<Plus className="size-5" />
Add line
</Button>
<h2 className="text-base font-semibold text-foreground">Lines</h2>
{/* Mobile has no table, so the "Add item" box sits above the cards; on desktop it's
the last row of the table, under the Item column. */}
<SuggestInput
options={lineComboOptions}
onSelect={addFromSuggestion}
// A barcode typed/scanned here (no suggestion matches it) is looked up as a scan.
onEnterWithoutMatch={handleScan}
browsable
keepOpenOnSelect
autoFocus
placeholder="Add item — type SKU, name or bundle…"
emptyText="No items or bundles match."
className="w-full md:hidden"
aria-label="Add item"
/>
{lines.length === 0 && (
<div className="rounded-lg border border-dashed py-10 text-center text-sm text-muted-foreground md:hidden">
No items yet — start typing in &quot;Add item&quot; above, or scan a barcode.
</div>
</div>
)}
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
@@ -619,6 +781,9 @@ function NewSalesInvoiceContent() {
{row.lines.map((l) => (
<li key={l.key}>
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
{stockShortage(l) !== null && (
<span className="text-destructive"> — only {stockShortage(l)} available</span>
)}
</li>
))}
</ul>
@@ -629,7 +794,6 @@ function NewSalesInvoiceContent() {
size="icon"
className="shrink-0"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
@@ -649,10 +813,12 @@ function NewSalesInvoiceContent() {
aria-label="Bundle quantity"
/>
</div>
<div>
<Label className="text-sm">Free qty</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
</div>
{allowFreeItems && (
<div>
<Label className="text-sm">Free qty</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
</div>
)}
</div>
<div className="flex items-center justify-between border-t pt-3">
@@ -672,13 +838,7 @@ function NewSalesInvoiceContent() {
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item {idx + 1}</Label>
<Combobox
items={lineComboOptions}
value={line.itemId ? `item:${line.itemId}` : null}
onValueChange={(v) => selectLineOption(line.key, v)}
placeholder="Search item or bundle…"
className="h-11 text-sm"
/>
<p className="py-1 text-sm font-medium text-foreground">{itemLabel(line.itemId)}</p>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
)}
@@ -689,7 +849,6 @@ function NewSalesInvoiceContent() {
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
>
<Trash2 className="size-5" />
@@ -712,42 +871,55 @@ function NewSalesInvoiceContent() {
inputMode="numeric"
value={line.qty}
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
{...navProps(line.key, "qty")}
aria-invalid={stockShortage(line) !== null || undefined}
className="h-11 text-sm tabular-nums"
/>
{stockShortage(line) !== null && (
<p className="mt-1 text-xs text-destructive">
Exceeds stock — only {stockShortage(line)} available
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Free</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</div>
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="0.5"
inputMode="decimal"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-sm tabular-nums"
/>
</div>
{allowFreeItems && (
<div>
<Label className="text-sm">Free</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
{...navProps(line.key, "free")}
className="h-11 text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</div>
)}
{allowDiscounts && (
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="0.5"
inputMode="decimal"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
{...navProps(line.key, "disc")}
className="h-11 text-sm tabular-nums"
/>
</div>
)}
</div>
<div>
@@ -759,11 +931,12 @@ function NewSalesInvoiceContent() {
inputMode="decimal"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
{...navProps(line.key, "price")}
className="h-11 text-sm tabular-nums"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
{suggestedPriceFor(line) !== null
? `Suggested: ${lkr.format(suggestedPriceFor(line) ?? 0)}`
: "No price suggestion available"}
</p>
</div>
@@ -786,9 +959,9 @@ function NewSalesInvoiceContent() {
<TableHead className="h-12 min-w-72 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Free</TableHead>
{allowFreeItems && <TableHead className="h-12 px-3 text-sm text-right">Free</TableHead>}
<TableHead className="h-12 px-3 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>
{allowDiscounts && <TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>}
<TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
@@ -808,6 +981,9 @@ function NewSalesInvoiceContent() {
{row.lines.map((l) => (
<li key={l.key}>
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
{stockShortage(l) !== null && (
<span className="text-destructive"> — only {stockShortage(l)} available</span>
)}
</li>
))}
</ul>
@@ -825,11 +1001,13 @@ function NewSalesInvoiceContent() {
/>
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm">
{bundleFreeQty || "—"}
</TableCell>
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
{allowFreeItems && (
<TableCell className="px-3 py-3 align-top text-right text-sm">
{bundleFreeQty || "—"}
</TableCell>
)}
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
{allowDiscounts && <TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>}
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
</TableCell>
@@ -839,8 +1017,7 @@ function NewSalesInvoiceContent() {
variant="ghost"
size="icon"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
</Button>
@@ -857,13 +1034,7 @@ function NewSalesInvoiceContent() {
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Combobox
items={lineComboOptions}
value={line.itemId ? `item:${line.itemId}` : null}
onValueChange={(v) => selectLineOption(line.key, v)}
placeholder="Search item or bundle…"
className="h-11 text-sm"
/>
<div className="flex min-h-11 items-center text-sm font-medium text-foreground">{itemLabel(line.itemId)}</div>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">
Lot: {line.batchNo ?? line.serialNo}
@@ -882,24 +1053,34 @@ function NewSalesInvoiceContent() {
step="1"
value={line.qty}
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
{...navProps(line.key, "qty")}
aria-invalid={stockShortage(line) !== null || undefined}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-right text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
{stockShortage(line) !== null && (
<p className="mt-1 text-right text-xs text-destructive">
Exceeds stock — only {stockShortage(line)} available
</p>
)}
</TableCell>
{allowFreeItems && (
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
{...navProps(line.key, "free")}
className="h-11 text-right text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</TableCell>
)}
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
@@ -907,25 +1088,29 @@ function NewSalesInvoiceContent() {
step="1"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
{...navProps(line.key, "price")}
className="h-11 text-right text-sm tabular-nums"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
{suggestedPriceFor(line) !== null
? `Suggested: ${lkr.format(suggestedPriceFor(line) ?? 0)}`
: "No price suggestion available"}
</p>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
{allowDiscounts && (
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
{...navProps(line.key, "disc")}
className="h-11 text-right text-sm tabular-nums"
/>
</TableCell>
)}
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(lineNet)}</div>
</TableCell>
@@ -935,8 +1120,7 @@ function NewSalesInvoiceContent() {
variant="ghost"
size="icon"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
@@ -944,6 +1128,25 @@ function NewSalesInvoiceContent() {
</TableRow>
)
})}
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{displayRows.length + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<SuggestInput
options={lineComboOptions}
onSelect={addFromSuggestion}
onEnterWithoutMatch={handleScan}
browsable
keepOpenOnSelect
autoFocus
placeholder="Search item — SKU, name or bundle…"
emptyText="No items or bundles match."
aria-label="Add item"
/>
</TableCell>
<TableCell colSpan={5 + (allowFreeItems ? 1 : 0) + (allowDiscounts ? 1 : 0)}className="px-3 py-3 align-middle text-sm text-muted-foreground">
{lines.length === 0 && "No items yet — search for an item, or scan a barcode."}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
@@ -953,10 +1156,12 @@ function NewSalesInvoiceContent() {
<span className="text-sm text-muted-foreground">Gross</span>
<span className="tabular-nums">{lkr.format(grossTotal)}</span>
</div>
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Discount</span>
<span className="tabular-nums">-{lkr.format(discountTotal)}</span>
</div>
{allowDiscounts && (
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Discount</span>
<span className="tabular-nums">{lkr.format(discountTotal)}</span>
</div>
)}
<div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Tax</span>
<span className="tabular-nums">{lkr.format(taxTotal)}</span>
@@ -968,13 +1173,28 @@ function NewSalesInvoiceContent() {
</div>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"}
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Label className="shrink-0 text-base">Invoice type</Label>
<Select<SalesInvoiceType> value={invoiceType} onValueChange={(v) => v && setInvoiceType(v)}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Cash" className="text-base">Cash</SelectItem>
<SelectItem value="Credit" className="text-base">Credit</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"}
</Button>
</div>
</div>
</>
)}
@@ -215,7 +215,7 @@ export default function SalesInvoicesPage() {
{rows !== null ? (
<>
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
<StatCard label="Discount total" value={`-${formatAmount(discountTotal)}`} icon={Tag} />
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
</>
) : (
@@ -438,7 +438,7 @@ export default function SalesInvoicesPage() {
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex flex-col items-start gap-1">
@@ -1,5 +1,5 @@
import Link from "next/link"
import { Building2, LayoutTemplate, MapPin, SlidersHorizontal } from "lucide-react"
import { Building2, LayoutTemplate, MapPin, ShoppingCart, SlidersHorizontal } from "lucide-react"
const cards = [
{
@@ -20,6 +20,12 @@ const cards = [
icon: SlidersHorizontal,
description: "Configure product and master-data options",
},
{
title: "Sales Configuration",
href: "/dashboard/settings/sales-configuration",
icon: ShoppingCart,
description: "Allow cash customer sales or require picking a customer",
},
{
title: "Invoice Template",
href: "/dashboard/settings/invoice-template",
@@ -34,7 +40,7 @@ export default function ConfigurationPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Configuration</h1>
<p className="text-base text-muted-foreground">
Product options, invoice presentation, and company details used across the ERP.
Product and sales options, invoice presentation, and company details used across the ERP.
</p>
</div>
@@ -0,0 +1,289 @@
"use client"
import { useEffect, useState } from "react"
import { Settings } from "lucide-react"
import { salesConfigApi } from "@/lib/api/sales-config"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { Warehouse } from "@/types/master-data"
import { SalesConfig, UpdateSalesConfigRequest } from "@/types/sales"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Sales Configuration — singleton sales toggles. "Allow cash customer": on, New Sale offers a
* generic "Cash Customer" (and defaults to it) alongside the customer list; off, a real customer
* must be picked from the list, and the server rejects the cash customer. "Allow free items":
* off hides the Free qty column on sale lines, and the server rejects a free qty. "Allow
* discounts": same, for the Disc % column and line discounts. "Allow
* warehouse selection" + default sales warehouse: a saved default pins Create Sale to that one
* warehouse (server-forced too); with none saved, the picker shows unless selection is off.
*/
export default function SalesConfigurationPage() {
const [config, setConfig] = useState<SalesConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
function load() {
setError(null)
salesConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
const savedDefault = warehouses.find((w) => w.warehouseId === config?.defaultWarehouseId)
const savedDefaultInList = savedDefault !== undefined
const savedDefaultLabel = savedDefault
? `${savedDefault.code} — ${savedDefault.name}`
: (config?.defaultWarehouseName ?? `Warehouse #${config?.defaultWarehouseId}`)
// PUT is a full replacement — send every flag, overriding just the one toggled.
async function saveFlag(patch: Partial<UpdateSalesConfigRequest>, message: string) {
if (!config) return
setSaving(true)
try {
const res = await salesConfigApi.update(
{
allowCashCustomer: config.allowCashCustomer,
allowFreeItems: config.allowFreeItems,
allowDiscounts: config.allowDiscounts,
allowOverStockSale: config.allowOverStockSale,
allowWarehouseSelection: config.allowWarehouseSelection,
defaultWarehouseId: config.defaultWarehouseId,
...patch,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", message)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Sales Configuration</h1>
<p className="text-base text-muted-foreground">Choose how sales are raised in this deployment.</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-40 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<Settings className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Customers</h2>
</div>
<div className="border-t" />
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowCashCustomer}
onCheckedChange={(next) =>
saveFlag({ allowCashCustomer: next }, `Cash customer ${next ? "allowed" : "not allowed"}.`)
}
disabled={saving}
aria-label="Allow cash customer"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow cash customer</span>
<span className="text-sm text-muted-foreground">
On: New Sale starts on &quot;{config.cashCustomerName ?? "Cash Customer"}&quot; for walk-in sales,
and any customer can still be picked from the dropdown. Off: every sale must pick a
customer from the dropdown.
</span>
</div>
</div>
<div className="flex items-center gap-2 pt-4">
<Settings className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Item lines</h2>
</div>
<div className="border-t" />
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowFreeItems}
onCheckedChange={(next) =>
saveFlag({ allowFreeItems: next }, `Free items ${next ? "allowed" : "not allowed"}.`)
}
disabled={saving}
aria-label="Allow free items"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow free items</span>
<span className="text-sm text-muted-foreground">
On: sale item lines show a Free qty column, auto-filled from active Free Issue
deals and editable. Off: the Free column is hidden and sales can&apos;t include free
items.
</span>
</div>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowDiscounts}
onCheckedChange={(next) =>
saveFlag({ allowDiscounts: next }, `Discounts ${next ? "allowed" : "not allowed"}.`)
}
disabled={saving}
aria-label="Allow discounts"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow discounts</span>
<span className="text-sm text-muted-foreground">
On: sale item lines show a Disc % column for a line discount. Off: the Disc % column
is hidden and sales can&apos;t include line discounts.
</span>
</div>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowOverStockSale}
onCheckedChange={(next) =>
saveFlag({ allowOverStockSale: next }, `Selling beyond stock ${next ? "allowed" : "not allowed"}.`)
}
disabled={saving}
aria-label="Allow selling beyond stock"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow selling beyond stock</span>
<span className="text-sm text-muted-foreground">
On: a sale can be posted even when a line&apos;s quantity is more than the stock in its
warehouse — stock goes negative and is settled from the next stock received. Off: a
sale can&apos;t be posted until there is enough stock for every line.
</span>
</div>
</div>
<div className="flex items-center gap-2 pt-4">
<Settings className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Warehouse</h2>
</div>
<div className="border-t" />
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.allowWarehouseSelection}
onCheckedChange={(next) => {
// Turning selection off needs somewhere for sales to go — the server rejects it too.
if (!next && !config.defaultWarehouseId) {
toast.error("Pick a default warehouse first", "Choose the default sales warehouse below, then turn this off.")
return
}
saveFlag({ allowWarehouseSelection: next }, `Warehouse selection ${next ? "enabled" : "disabled"}.`)
}}
disabled={saving}
aria-label="Allow warehouse selection"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Allow warehouse selection</span>
<span className="text-sm text-muted-foreground">
On: with no default warehouse saved below, Create Sale shows a Warehouse picker.
Off: a default warehouse must be saved. Whenever a default is saved, Create Sale
shows only that warehouse.
</span>
</div>
</div>
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
<span className="text-base font-medium text-foreground">Default sales warehouse</span>
<Select
value={config.defaultWarehouseId ? String(config.defaultWarehouseId) : ""}
onValueChange={(v) => {
if (!v) return
const picked = warehouses.find((w) => w.warehouseId === Number(v))
saveFlag({ defaultWarehouseId: Number(v) }, `Default sales warehouse: ${picked?.name ?? v}.`)
}}
disabled={saving}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={config.defaultWarehouseName ?? "Select warehouse"} />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)} className="text-base">
{w.code} — {w.name}
</SelectItem>
))}
{/* The warehouse list can be branch-scoped — keep the saved default selectable
(and labelled in the trigger) even when it isn't in this user's list. */}
{config.defaultWarehouseId && !savedDefaultInList && (
<SelectItem value={String(config.defaultWarehouseId)} className="text-base">
{config.defaultWarehouseName ?? `Warehouse #${config.defaultWarehouseId}`}
</SelectItem>
)}
</SelectContent>
</Select>
{config.defaultWarehouseId ? (
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-foreground">
Create Sale shows only: <span className="font-medium">{savedDefaultLabel}</span>
</span>
{/* Clearing brings the picker back — only possible while selection is allowed. */}
{config.allowWarehouseSelection && (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving}
onClick={() => saveFlag({ defaultWarehouseId: null }, "Default sales warehouse cleared.")}
>
Clear
</Button>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">
Not set — Create Sale lets the user pick a warehouse. Required to turn warehouse
selection off.
</span>
)}
</div>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
+6
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Inter, Geist_Mono } from "next/font/google";
import { Suspense } from "react";
import { Providers } from "@/components/providers";
import { RouteProgress } from "@/components/Layouts/RouteProgress";
import "./globals.css";
const inter = Inter({
@@ -31,6 +33,10 @@ export default function RootLayout({
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
{/* useSearchParams needs a Suspense boundary to keep pages statically renderable. */}
<Suspense fallback={null}>
<RouteProgress />
</Suspense>
<Providers>{children}</Providers>
</body>
</html>
@@ -43,14 +43,12 @@ import {
PieChart,
Receipt,
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
ShieldCheck,
ShoppingCart,
SlidersHorizontal,
Tag,
Truck,
Undo2,
Users,
@@ -73,11 +71,7 @@ const navItems: {
/** Where clicking the row actually navigates, if different from `href`. `href` itself
* stays the section prefix used to decide whether this row is "active". */
landingHref?: string
/** This item's own href has no page of its own (no landingHref either) — clicking the
* row should only expand/collapse its children, never navigate. */
expandOnly?: boolean
icon: LucideIcon
chevron?: boolean
children?: {
title: string
code: string
@@ -94,12 +88,16 @@ const navItems: {
code: "products",
href: "/dashboard/products",
icon: Package,
chevron: true,
children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
// Category, Brand and UOM share one tabbed page — any of the three permissions shows it.
{
title: "Category/Brand/UOM",
code: "products.category",
href: "/dashboard/products/categories",
icon: ListTree,
altCodes: ["products.brand", "products.uom"],
},
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
],
},
@@ -111,7 +109,6 @@ const navItems: {
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
landingHref: "/dashboard/procurement/purchase-orders",
icon: ClipboardList,
chevron: true,
children: [
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
@@ -124,9 +121,11 @@ const navItems: {
href: "/dashboard/sales",
landingHref: "/dashboard/sales/invoices",
icon: ReceiptText,
chevron: true,
children: [
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
// Shortcut straight to the New Sale form — reuses the sales.invoices permission code (same
// access as the Sales list), so it needs no seeded SubNavItem of its own.
{ title: "Create Sale", code: "sales.invoices", href: "/dashboard/sales/invoices/new", icon: ShoppingCart },
// Reuses the sales.invoices permission code — customers aren't yet their own
// seeded NavItem/Permission on the backend, so this rides along with Sales access.
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
@@ -137,7 +136,7 @@ const navItems: {
],
},
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck },
{
title: "Stock",
code: "stock",
@@ -146,7 +145,6 @@ const navItems: {
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
landingHref: "/dashboard/stock/ledger",
icon: Warehouse,
chevron: true,
children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
@@ -158,14 +156,13 @@ const navItems: {
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
],
},
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2 },
{
title: "Production",
code: "production",
href: "/dashboard/production",
landingHref: "/dashboard/production/runs",
icon: Factory,
chevron: true,
children: [
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
@@ -178,7 +175,6 @@ const navItems: {
href: "/dashboard/hrm",
landingHref: "/dashboard/hrm/employees",
icon: IdCard,
chevron: true,
children: [
{ title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users },
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
@@ -193,7 +189,6 @@ const navItems: {
code: "ledgers",
href: "/dashboard/ledgers",
icon: Landmark,
chevron: true,
children: [
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
@@ -209,7 +204,6 @@ const navItems: {
code: "accounts",
href: "/dashboard/accounts",
icon: CreditCard,
chevron: true,
children: [
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
@@ -220,9 +214,7 @@ const navItems: {
title: "Settings",
code: "settings",
href: "/dashboard/settings",
expandOnly: true,
icon: Settings,
chevron: true,
children: [
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
@@ -329,49 +321,46 @@ function SidebarContent({
isActive ? "bg-primary/10" : "hover:bg-muted"
)}
>
<Link
href={item.landingHref ?? item.href}
title={iconOnly ? item.title : undefined}
onClick={() => {
onClose?.()
if (hasChildren) setExpanded((prev) => (prev[item.code] ? prev : { ...prev, [item.code]: true }))
}}
className={cn(
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
iconOnly && "justify-center px-0",
isActive ? "text-primary" : "text-foreground"
)}
>
<item.icon
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && (
<>
<span className="flex-1">{item.title}</span>
{item.chevron && !hasChildren && (
<ChevronRight
className={cn("size-4 shrink-0", isActive ? "text-primary/70" : "text-muted-foreground")}
/>
)}
</>
)}
</Link>
{hasChildren && (
{hasChildren ? (
// Parents with sub-items only expand/collapse on click — navigation
// happens through the sub-items themselves.
<button
type="button"
onClick={() => toggleExpand(item.code)}
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
aria-expanded={isOpen}
className={cn(
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/10",
isActive ? "text-primary" : "text-muted-foreground"
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-left text-base font-normal",
isActive ? "text-primary" : "text-foreground"
)}
>
<item.icon
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
<span className="flex-1">{item.title}</span>
<ChevronRight
className={cn("size-4 transition-transform duration-300 ease-in-out", isOpen && "rotate-90")}
className={cn(
"size-4 shrink-0 transition-transform duration-300 ease-in-out",
isActive ? "text-primary" : "text-muted-foreground",
isOpen && "rotate-90"
)}
/>
</button>
) : (
<Link
href={item.landingHref ?? item.href}
title={iconOnly ? item.title : undefined}
onClick={onClose}
className={cn(
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
iconOnly && "justify-center px-0",
isActive ? "text-primary" : "text-foreground"
)}
>
<item.icon
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && <span className="flex-1">{item.title}</span>}
</Link>
)}
</div>
@@ -132,10 +132,10 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products") return "Items"
if (pathname === "/dashboard/products/new") return "New Item"
if (pathname === "/dashboard/products/categories") return "Categories"
if (pathname === "/dashboard/products/categories") return "Category/Brand/UOM"
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/item-types") return "Item Variants"
if (pathname === "/dashboard/products/warranty-periods") return "Warranty Periods"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
if (pathname === "/dashboard/settings/company-profile") return "Company Profile"
@@ -0,0 +1,102 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { usePathname, useSearchParams } from "next/navigation"
/**
* Thin top-of-page progress bar for client-side navigations.
*
* The App Router has no "navigation started" event, so the start is detected from clicks on
* internal links (capture phase, before next/link handles them) and back/forward. The route
* committing — pathname or query changing — finishes it. Programmatic router.push() isn't
* seen at start; app/dashboard/loading.tsx covers those with an in-content spinner.
*/
export function RouteProgress() {
const pathname = usePathname()
const searchParams = useSearchParams()
const [progress, setProgress] = useState(0)
const [visible, setVisible] = useState(false)
// Set by the mount effect; lets the route-change effect end the bar.
const finishRef = useRef<() => void>(() => {})
useEffect(() => {
let active = false
let trickle: ReturnType<typeof setInterval> | null = null
let safety: ReturnType<typeof setTimeout> | null = null
let hide: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (trickle) clearInterval(trickle)
if (safety) clearTimeout(safety)
if (hide) clearTimeout(hide)
trickle = safety = hide = null
}
function start() {
if (active) return
active = true
clearTimers()
setVisible(true)
setProgress(8)
// Ease toward 90% — never reaches the end until the route actually commits.
trickle = setInterval(() => setProgress((p) => (p < 90 ? p + (90 - p) * 0.1 : p)), 200)
// Never leave a bar hanging if a navigation is cancelled or fails silently.
safety = setTimeout(finish, 15000)
}
function finish() {
if (!active) return
active = false
clearTimers()
setProgress(100)
hide = setTimeout(() => {
setVisible(false)
setProgress(0)
}, 250)
}
finishRef.current = finish
function onClick(e: MouseEvent) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const anchor = (e.target as Element | null)?.closest?.("a")
if (!anchor || !anchor.href) return
if (anchor.target && anchor.target !== "_self") return
if (anchor.hasAttribute("download")) return
const next = new URL(anchor.href, window.location.href)
if (next.origin !== window.location.origin) return
// API downloads (e.g. document links) aren't page navigations.
if (next.pathname.startsWith("/api/")) return
// Same page (or hash-only jump) — nothing will commit, so don't start.
if (next.pathname === window.location.pathname && next.search === window.location.search) return
start()
}
document.addEventListener("click", onClick, true)
window.addEventListener("popstate", start)
return () => {
document.removeEventListener("click", onClick, true)
window.removeEventListener("popstate", start)
clearTimers()
}
}, [])
// Route committed ⇒ done. Keyed on the full URL so query-only navigations count too.
const url = `${pathname}?${searchParams.toString()}`
useEffect(() => {
finishRef.current()
}, [url])
return (
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-100 h-0.5 print:hidden"
style={{ opacity: visible ? 1 : 0, transition: "opacity 250ms ease" }}
>
<div
className="h-full bg-primary shadow-[0_0_8px_var(--color-primary)]"
style={{ width: `${progress}%`, transition: "width 200ms ease-out" }}
/>
</div>
)
}
@@ -0,0 +1,203 @@
"use client"
import { useEffect, useState } from "react"
import { Plus, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data"
import { Brand } from "@/types/master-data"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
// Large enough to show "all brands" in one grid — the chip layout replaces the
// old table's pagination, so we just ask for everything in one page.
const ALL_ITEMS_PAGE_SIZE = 500
/** Brand master — the Brand tab of the Category/Brand/UOM page. */
export function BrandPanel() {
const [brands, setBrands] = useState<Brand[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [editName, setEditName] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
function load() {
setError(null)
brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setBrands(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await brandsApi.create({ name })
toast.success("Brand created", result.data.name)
setName("")
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create brand", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setTogglingId(brand.brandId)
try {
await brandsApi.updateStatus(brand.brandId, next)
load()
} catch (err) {
toast.error("Could not update brand status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
function openEdit(brand: Brand) {
setEditing(brand)
setEditName(brand.name)
setEditErrors({})
setEditOpen(true)
}
async function handleEditSubmit() {
if (!editing) return
const nextErrors = validateBrandName(editName)
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setEditSubmitting(true)
try {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
toast.success("Brand updated", result.data.name)
setEditOpen(false)
setEditing(null)
load()
} catch (err) {
setEditErrors({ name: errorMessage(err) })
toast.error("Could not update brand", errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add Brand
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && brands === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && brands !== null && brands.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No brands yet.</p>
</div>
)}
{!error && brands !== null && brands.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{brands.map((b) => (
<label
key={b.brandId}
onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId}
className="shrink-0"
/>
<span className="truncate font-medium">{b.name}</span>
</label>
))}
</div>
)}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit brand</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
<Input
id="brand-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-invalid={!!editErrors.name}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.name } : 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={() => setEditOpen(false)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,486 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Category, ProductConfig } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type SortOrder = "asc" | "desc"
type SortKey = "categoryId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Item category master (FR-MD-04) — the Category tab of the Category/Brand/UOM page. */
export function CategoryPanel() {
const [categories, setCategories] = useState<Category[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
// always returns Name ascending, so this doesn't hold across page turns or other columns.
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Category | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null)
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
useEffect(() => {
productConfig().then(setConfig).catch(() => setConfig(null))
}, [])
// Subcategories are their own resource — there's no count on the Category DTO itself,
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
// is needed) once the feature is on and the page's categories are known.
useEffect(() => {
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
setSubCategoryCounts({})
return
}
let cancelled = false
Promise.all(
categories.map((c) =>
categoriesApi
.listSubCategories(c.categoryId, { pageSize: 1 })
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
.catch(() => [c.categoryId, 0] as const)
)
).then((entries) => {
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
})
return () => {
cancelled = true
}
}, [config?.subcategoriesEnabled, categories])
useEffect(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
}, [search, status])
function load() {
setError(null)
categoriesApi
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize })
.then((res) => {
setCategories(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [search, status, page, pageSize])
const hasFilters = search.trim().length > 0 || status !== "All"
const sortedCategories = categories
? [...categories].sort((a, b) => {
const cmp =
sortKey === "categoryId"
? a.categoryId - b.categoryId
: sortKey === "createdAt"
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: a[sortKey].localeCompare(b[sortKey])
return sortOrder === "asc" ? cmp : -cmp
})
: null
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
function openCreateDialog() {
setEditing(null)
setName("")
setErrors({})
setOpen(true)
}
function openEditDialog(category: Category) {
setEditing(category)
setName(category.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
let result
if (editing) {
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
const current = await categoriesApi.get(editing.categoryId)
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
} else {
result = await categoriesApi.create({ name })
}
toast.success(editing ? "Category updated" : "Category created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(category: Category) {
const next = category.status === "Active" ? "Inactive" : "Active"
setDeletingId(category.categoryId)
try {
await categoriesApi.updateStatus(category.categoryId, next)
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
load()
} catch (err) {
toast.error("Could not update category status", errorMessage(err))
} finally {
setDeletingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search categories…"
className="h-14 w-full pl-11 text-base"
aria-label="Search categories"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
<DialogDescription>Give the category a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : 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={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && categories === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && categories !== null && categories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ListTree className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No categories match your search/filter." : "No categories yet."}
</p>
</div>
)}
{!error && categories !== null && categories.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{sortedCategories!.map((c) => (
<div key={c.categoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{c.name}</p>
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
{config?.subcategoriesEnabled && (
<div>
<p className="text-muted-foreground">Subcategories</p>
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
</div>
)}
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedCategories!.map((c) => (
<TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"border-transparent",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
)}
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{pagination && (
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
)}
</>
)}
</div>
)
}
function SortableHeader({
label,
active,
order,
onClick,
}: {
label: string
active: boolean
order: SortOrder
onClick: () => void
}) {
return (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
@@ -0,0 +1,221 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { ImageIcon, ImagePlus, Trash2 } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { ItemImage } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
/** Mirrors the server's rules (ItemImageService) so a bad pick fails fast, before any upload. */
export const ITEM_IMAGE_ACCEPT = "image/jpeg,image/png,image/webp"
export const ITEM_IMAGE_MAX_BYTES = 5 * 1024 * 1024
export const ITEM_IMAGE_MAX_COUNT = 10
/** Client-side pre-check; returns a reason the file can't be used, or null. */
export function itemImageProblem(file: File): string | null {
if (!ITEM_IMAGE_ACCEPT.split(",").includes(file.type)) return `${file.name}: use a JPG, PNG or WebP image.`
if (file.size > ITEM_IMAGE_MAX_BYTES) return `${file.name}: larger than 5 MB.`
return null
}
/**
* Photos for one existing item. Read-only unless `editable` (the item's Edit dialog) — there
* images can be removed, and added too when `canUpload` (ProductConfig.itemImagesEnabled).
* Changes apply immediately, not on the dialog's Save. Hides itself when there is nothing
* to show and nothing the user could do.
*/
export function ItemImagesPanel({
itemId,
editable = false,
canUpload = false,
}: {
itemId: number
editable?: boolean
canUpload?: boolean
}) {
const allowUpload = editable && canUpload
const [images, setImages] = useState<ItemImage[] | null>(null)
const [uploading, setUploading] = useState(0)
const [viewing, setViewing] = useState<ItemImage | null>(null)
const [confirmDelete, setConfirmDelete] = useState<ItemImage | null>(null)
const [deleting, setDeleting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
let cancelled = false
itemsApi
.listImages(itemId)
.then((res) => {
if (!cancelled) setImages(res)
})
.catch(() => {
if (!cancelled) setImages([])
})
return () => {
cancelled = true
}
}, [itemId])
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
const room = ITEM_IMAGE_MAX_COUNT - (images?.length ?? 0)
const picked = Array.from(files)
if (picked.length > room) toast.warning("Too many images", `An item can have ${ITEM_IMAGE_MAX_COUNT}; only the first ${room} will be added.`)
const toUpload: File[] = []
for (const file of picked.slice(0, Math.max(room, 0))) {
const problem = itemImageProblem(file)
if (problem) toast.error("Image skipped", problem)
else toUpload.push(file)
}
setUploading(toUpload.length)
for (const file of toUpload) {
try {
const image = await itemsApi.uploadImage(itemId, file)
setImages((prev) => [...(prev ?? []), image])
} catch (err) {
toast.error(`Could not upload ${file.name}`, errorMessage(err))
} finally {
setUploading((n) => n - 1)
}
}
}
async function handleDelete() {
if (!confirmDelete) return
setDeleting(true)
try {
await itemsApi.deleteImage(itemId, confirmDelete.itemImageId)
setImages((prev) => (prev ?? []).filter((i) => i.itemImageId !== confirmDelete.itemImageId))
if (viewing?.itemImageId === confirmDelete.itemImageId) setViewing(null)
setConfirmDelete(null)
} catch (err) {
toast.error("Could not remove image", errorMessage(err))
} finally {
setDeleting(false)
}
}
if (images !== null && images.length === 0 && !allowUpload) return null
const full = (images?.length ?? 0) >= ITEM_IMAGE_MAX_COUNT
return (
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
<ImageIcon className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Images</h2>
{images && images.length > 0 && (
<span className="text-sm text-muted-foreground">
{images.length} / {ITEM_IMAGE_MAX_COUNT}
</span>
)}
</div>
{images === null ? (
<Skeleton className="h-28 w-full" />
) : (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((img, index) => (
<div key={img.itemImageId} className="group relative aspect-square overflow-hidden rounded-lg border bg-muted">
<button type="button" className="size-full" onClick={() => setViewing(img)} aria-label={`View ${img.fileName}`}>
{/* eslint-disable-next-line @next/next/no-img-element -- authenticated API image, not a static asset */}
<img src={itemsApi.imageUrl(itemId, img.itemImageId)} alt={img.fileName} className="size-full object-cover" />
</button>
{index === 0 && (
<span className="absolute top-1.5 left-1.5 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium">Main</span>
)}
{editable && (
<button
type="button"
onClick={() => setConfirmDelete(img)}
aria-label={`Remove ${img.fileName}`}
className="absolute top-1.5 right-1.5 flex size-7 items-center justify-center rounded-md bg-background/90 text-destructive opacity-100 shadow-sm transition-opacity hover:bg-background sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
))}
{Array.from({ length: uploading }).map((_, i) => (
<Skeleton key={`up-${i}`} className="aspect-square w-full rounded-lg" />
))}
{allowUpload && !full && (
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading > 0}
className="flex aspect-square flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
>
<ImagePlus className="size-6" />
<span className="text-xs font-medium">Add images</span>
</button>
)}
</div>
)}
{editable && (
<p className="text-xs text-muted-foreground">
{allowUpload && "JPG, PNG or WebP, up to 5 MB each. The first image is the main one. "}
Image changes are saved immediately.
</p>
)}
{allowUpload && (
<input
ref={inputRef}
type="file"
accept={ITEM_IMAGE_ACCEPT}
multiple
className="hidden"
onChange={(e) => {
handleFiles(e.target.files)
e.target.value = "" // allow re-picking the same file
}}
/>
)}
<Dialog open={viewing !== null} onOpenChange={(open) => !open && setViewing(null)}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="truncate">{viewing?.fileName}</DialogTitle>
</DialogHeader>
{viewing && (
// eslint-disable-next-line @next/next/no-img-element -- authenticated API image, not a static asset
<img
src={itemsApi.imageUrl(itemId, viewing.itemImageId)}
alt={viewing.fileName}
className="max-h-[70vh] w-full rounded-lg object-contain"
/>
)}
</DialogContent>
</Dialog>
<Dialog open={confirmDelete !== null} onOpenChange={(open) => !open && setConfirmDelete(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remove image?</DialogTitle>
<DialogDescription>{confirmDelete?.fileName} will be permanently deleted from this item.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDelete(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting ? "Removing…" : "Remove"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,133 @@
"use client"
import { useEffect, useState } from "react"
import { Plus, Ruler } from "lucide-react"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { Uom } from "@/types/master-data"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
// Large enough to show every UOM in one grid — the chip layout has no pagination.
const ALL_ITEMS_PAGE_SIZE = 500
/** Unit-of-measure master — the UOM tab of the Category/Brand/UOM page. */
export function UomPanel() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
uomsApi
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
.then((res) => setUoms(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleActive(uom: Uom) {
const next = uom.status === "Active" ? "Inactive" : "Active"
setTogglingId(uom.uomId)
try {
await uomsApi.updateStatus(uom.uomId, next)
load()
} catch (err) {
toast.error("Could not update UOM status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
aria-invalid={!!errors.name}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button size="lg" onClick={handleAdd} disabled={submitting}>
<Plus className="size-5" />
Add UOM
</Button>
</div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && uoms === null && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && uoms !== null && uoms.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Ruler className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No UOMs yet.</p>
</div>
)}
{!error && uoms !== null && uoms.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{uoms.map((u) => (
<label
key={u.uomId}
title="Tick to activate/deactivate"
className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
>
<Checkbox
checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId}
className="shrink-0"
/>
<span className="truncate font-medium">{u.name}</span>
</label>
))}
</div>
)}
</div>
)
}
@@ -22,6 +22,9 @@ export interface ScanInputProps {
hint?: string
placeholder?: string
disabled?: boolean
/** Grab focus on mount/re-enable (default true). Turn off when another field on the page
* should get the cursor first (e.g. a type-ahead "Add item" box). */
autoFocus?: boolean
className?: string
}
@@ -31,6 +34,7 @@ export function ScanInput({
hint = "Scan item or lot barcode",
placeholder = "Scan or type a barcode, then press Enter",
disabled = false,
autoFocus = true,
className,
}: ScanInputProps) {
const [value, setValue] = useState("")
@@ -39,8 +43,8 @@ export function ScanInput({
// Refocus whenever the field becomes usable again (e.g. after being briefly disabled while a
// lookup for the previous scan was in flight) so a scanner's next read always lands here.
useEffect(() => {
if (!disabled) inputRef.current?.focus()
}, [disabled])
if (!disabled && autoFocus) inputRef.current?.focus()
}, [disabled, autoFocus])
function commit() {
const code = value.trim()
@@ -22,6 +22,8 @@ interface FilterBarProps {
advancedActive?: boolean
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
onClearAdvanced?: () => void
/** Puts the search box on the left and groups `children` + "Advanced" together on the right. */
searchFirst?: boolean
className?: string
}
@@ -33,59 +35,76 @@ interface FilterBarProps {
*/
export function FilterBar({
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
children, advanced, advancedActive, onClearAdvanced, className,
children, advanced, advancedActive, onClearAdvanced, searchFirst, className,
}: FilterBarProps) {
const searchBox = (
<div className="relative w-full lg:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="h-10 pl-9 pr-9"
aria-label={searchAriaLabel ?? searchPlaceholder}
/>
{search && (
<button
type="button"
onClick={() => onSearchChange("")}
aria-label="Clear search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
)
const advancedPopover = advanced && (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="relative shrink-0">
<SlidersHorizontal className="size-4" />
Advanced
{advancedActive && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
)}
</Button>
}
/>
<PopoverContent align="end" className="w-72 p-4">
<div className="flex flex-col gap-3">
{advanced}
{onClearAdvanced && (
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
Clear advanced filters
</Button>
)}
</div>
</PopoverContent>
</Popover>
)
if (searchFirst) {
return (
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between", className)}>
{searchBox}
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{children}
{advancedPopover}
</div>
</div>
)
}
return (
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
{children}
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<div className="relative w-full lg:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="h-10 pl-9 pr-9"
aria-label={searchAriaLabel ?? searchPlaceholder}
/>
{search && (
<button
type="button"
onClick={() => onSearchChange("")}
aria-label="Clear search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
{advanced && (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="relative shrink-0">
<SlidersHorizontal className="size-4" />
Advanced
{advancedActive && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
)}
</Button>
}
/>
<PopoverContent align="end" className="w-72 p-4">
<div className="flex flex-col gap-3">
{advanced}
{onClearAdvanced && (
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
Clear advanced filters
</Button>
)}
</div>
</PopoverContent>
</Popover>
)}
{searchBox}
{advancedPopover}
</div>
</div>
)
@@ -0,0 +1,254 @@
"use client"
import * as React from "react"
import { createPortal } from "react-dom"
import { ChevronDown, Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
export interface SuggestOption {
value: string
label: string
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
hint?: string
}
interface SuggestInputProps {
options: SuggestOption[]
/** Called with the picked option's value; the box then clears for the next entry. */
onSelect: (value: string) => void
placeholder?: string
emptyText?: string
maxResults?: number
/** Enter with text but no suggestion to pick (e.g. a barcode from a USB scanner, which types
* the code then presses Enter) — receives the typed text; the box then clears. */
onEnterWithoutMatch?: (text: string) => void
autoFocus?: boolean
/** Also works as a dropdown: clicking the box (or its arrow, or pressing ↓) lists every option
* before anything is typed. Off by default, so plain type-ahead boxes stay quiet on focus. */
browsable?: boolean
/** With `browsable`: after a pick the box stays focused, keeps its (selected) search text and
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
keepOpenOnSelect?: boolean
className?: string
"aria-label"?: string
}
/**
* Type-ahead "add" box: shows no list until the user types, then suggests matching options
* below the input. Every typed word must appear in the label (so "bolt 10" finds
* "BLT-10 — Hex bolt 10mm"); labels starting with the query rank first. Picking one fires
* `onSelect` and clears the box, so it works as a quick "add another" field rather than a
* value holder.
*/
export function SuggestInput({
options,
onSelect,
placeholder = "Type to search…",
emptyText = "No matches.",
maxResults = 8,
onEnterWithoutMatch,
autoFocus,
browsable = false,
keepOpenOnSelect = false,
className,
...rest
}: SuggestInputProps) {
const [query, setQuery] = React.useState("")
const [open, setOpen] = React.useState(false)
// Set only by an explicit click/arrow/↓ (never by plain focus), so an autoFocused box doesn't
// pop its full list open on page load.
const [browsing, setBrowsing] = React.useState(false)
const [highlight, setHighlight] = React.useState(0)
const listId = React.useId()
const listRef = React.useRef<HTMLDivElement>(null)
const matches = React.useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return browsing ? options : []
const words = q.split(/\s+/)
return options
.filter((o) => {
const label = o.label.toLowerCase()
return words.every((w) => label.includes(w))
})
.sort((a, b) => Number(b.label.toLowerCase().startsWith(q)) - Number(a.label.toLowerCase().startsWith(q)))
.slice(0, maxResults)
}, [options, query, maxResults, browsing])
const showList = open && (query.trim().length > 0 || browsing)
function openBrowse() {
if (!browsable) return
setBrowsing(true)
setOpen(true)
}
function close() {
setOpen(false)
setBrowsing(false)
}
// Keep the keyboard-highlighted option visible when arrowing through a long browse list.
React.useEffect(() => {
listRef.current?.querySelector(`[data-idx="${highlight}"]`)?.scrollIntoView({ block: "nearest" })
}, [highlight])
// The list is portalled to <body> with fixed positioning so it isn't clipped when the box sits
// inside a scroll container (e.g. a table wrapped in overflow-x-auto) — so track the box's
// on-screen position while it's open.
const wrapperRef = React.useRef<HTMLDivElement>(null)
const [rect, setRect] = React.useState<DOMRect | null>(null)
React.useLayoutEffect(() => {
if (!showList) return
const update = () => setRect(wrapperRef.current?.getBoundingClientRect() ?? null)
update()
window.addEventListener("scroll", update, true)
window.addEventListener("resize", update)
return () => {
window.removeEventListener("scroll", update, true)
window.removeEventListener("resize", update)
}
}, [showList])
// The box can also move without any scroll/resize — e.g. a pick adds a table row above it and
// pushes it down — so re-measure after every render too (only storing a real change, so this
// can't loop).
React.useLayoutEffect(() => {
if (!showList) return
const next = wrapperRef.current?.getBoundingClientRect()
if (next && (next.bottom !== rect?.bottom || next.left !== rect?.left || next.width !== rect?.width)) setRect(next)
})
function choose(option: SuggestOption) {
onSelect(option.value)
if (browsable && keepOpenOnSelect) {
// Keep the search text (and its filtered list) so similar items can be added one after
// another; the text is selected, so simply typing replaces it with a new search. The
// just-picked option stays highlighted for a quick repeat.
setBrowsing(true)
setOpen(true)
setHighlight(Math.max(matches.findIndex((o) => o.value === option.value), 0))
wrapperRef.current?.querySelector("input")?.select()
} else {
setQuery("")
close()
setHighlight(0)
}
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault()
setOpen(true)
if (!showList) {
openBrowse()
setHighlight(0)
return
}
setHighlight((h) => Math.min(h + 1, Math.max(matches.length - 1, 0)))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setHighlight((h) => Math.max(h - 1, 0))
} else if (e.key === "Enter") {
// Never submit a surrounding form from here — Enter only picks a suggestion.
e.preventDefault()
const option = matches[highlight]
if (option) {
choose(option)
} else if (onEnterWithoutMatch && query.trim()) {
onEnterWithoutMatch(query.trim())
setQuery("")
close()
}
} else if (e.key === "Escape") {
close()
}
}
return (
<div ref={wrapperRef} className={cn("relative", className)}>
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
setHighlight(0)
}}
onFocus={() => setOpen(true)}
onClick={openBrowse}
onBlur={close}
onKeyDown={onKeyDown}
placeholder={placeholder}
// Stop the browser's own autofill history from dropping down "previous entries".
autoComplete="off"
autoFocus={autoFocus}
className={cn("h-11 pl-9 text-sm", browsable && "pr-9")}
role="combobox"
aria-expanded={showList}
aria-controls={listId}
aria-autocomplete="list"
aria-label={rest["aria-label"]}
/>
{browsable && (
<button
type="button"
tabIndex={-1}
aria-label="Show all options"
// mousedown + preventDefault keeps focus in the input, so its blur doesn't close the list.
onMouseDown={(e) => {
e.preventDefault()
wrapperRef.current?.querySelector("input")?.focus()
if (showList) close()
else {
openBrowse()
setHighlight(0)
}
}}
className="absolute top-1/2 right-2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
>
<ChevronDown className={cn("size-4 transition-transform", showList && "rotate-180")} />
</button>
)}
{showList && rect && createPortal(
<div
ref={listRef}
id={listId}
role="listbox"
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
className="fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
>
{matches.length === 0 ? (
<p className="px-2.5 py-4 text-center text-sm text-muted-foreground">{emptyText}</p>
) : (
matches.map((option, idx) => (
<button
key={option.value}
type="button"
role="option"
data-idx={idx}
aria-selected={idx === highlight}
// mousedown (not click) so the input's blur doesn't close the list first.
onMouseDown={(e) => {
e.preventDefault()
choose(option)
}}
onMouseEnter={() => setHighlight(idx)}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left text-sm",
idx === highlight ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
)}
>
<span className="truncate">{option.label}</span>
{option.hint && <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
</button>
))
)}
</div>,
document.body,
)}
</div>
)
}
+26
View File
@@ -5,10 +5,12 @@ import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import {
CreateItemRequest,
Item,
ItemImage,
ItemListItem,
ItemOverview,
ItemReorderSetting,
ItemScanResult,
StockNature,
TrackingMode,
UpdateItemReorderRequest,
UpdateItemRequest,
@@ -34,8 +36,13 @@ export interface ListItemOverviewParams {
categoryId?: number
subCategoryId?: number
brandId?: number
trackingMode?: TrackingMode
stockNature?: StockNature
stock?: ItemStockFilter
}
export type ItemStockFilter = "InStock" | "OutOfStock" | "LowStock"
export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
@@ -46,6 +53,25 @@ export const itemsApi = {
overview(params: ListItemOverviewParams = {}): Promise<PagedResponse<ItemOverview>> {
return apiRequest<PagedResponse<ItemOverview>>(`/items/overview${buildQuery(params)}`)
},
listImages(itemId: number): Promise<ItemImage[]> {
return apiRequest<ItemImage[]>(`/items/${itemId}/images`)
},
/** JPG/PNG/WebP, ≤ 5 MB, ≤ 10 per item. 422 CONFIG_DISABLED while item images are off. */
uploadImage(itemId: number, file: File): Promise<ItemImage> {
const form = new FormData()
form.append("file", file)
return apiRequest<ItemImage>(`/items/${itemId}/images`, { method: "POST", body: form })
},
deleteImage(itemId: number, itemImageId: number): Promise<void> {
return apiRequest<void>(`/items/${itemId}/images/${itemImageId}`, { method: "DELETE" })
},
/** Same-origin and cookie-authenticated, so it works directly as an <img> src. */
imageUrl(itemId: number, itemImageId: number): string {
return `/api/v1/items/${itemId}/images/${itemImageId}/content`
},
get(itemId: number): Promise<ApiResult<Item>> {
return apiRequestWithETag<Item>(`/items/${itemId}`)
@@ -16,8 +16,7 @@ export const productConfigApi = {
/** All flags are required; a partial body is a 400, never a silent disable. */
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
},
}
},}
/** Read-only helper for screens that only need the flags. */
export function productConfig(): Promise<ProductConfig> {
@@ -0,0 +1,16 @@
// Sales configuration. Singleton - no id. When `allowCashCustomer` is on, sales can be raised
// against the generic "Cash Customer" (`cashCustomerId`) and New Sale defaults to it; when off,
// a real customer must be picked (the server rejects the cash customer).
import { apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { SalesConfig, UpdateSalesConfigRequest } from "@/types/sales"
export const salesConfigApi = {
get(): Promise<ApiResult<SalesConfig>> {
return apiRequestWithETag<SalesConfig>("/sales-config")
},
update(request: UpdateSalesConfigRequest, ifMatch: string): Promise<ApiResult<SalesConfig>> {
return apiRequestWithETag<SalesConfig>("/sales-config", { method: "PUT", body: request, ifMatch })
},
}
+12
View File
@@ -20,6 +20,8 @@ import {
SalesSlipStatus,
SalesSlipPostingCheck,
SalesSlipSummary,
SalesStockAvailability,
SalesSuggestedPrice,
UpdateSalesInvoiceRequest,
UpdateSalesSlipRequest,
} from "@/types/sales"
@@ -46,6 +48,16 @@ export const salesApi = {
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
},
/** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
},
/** Issuable stock for an item at a warehouse — New Sale's per-line shortage warning. */
getStockAvailability(itemId: number, warehouseId: number): Promise<SalesStockAvailability> {
return apiRequest<SalesStockAvailability>(`/sales-invoices/stock-availability${buildQuery({ itemId, warehouseId })}`)
},
getInvoice(salesInvoiceId: number): Promise<ApiResult<SalesInvoice>> {
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
},
@@ -0,0 +1,21 @@
// Custom warranty period master (Products → Warranty Periods). Defaults (3/6/12/18 months)
// are fixed client+server side and not returned here.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { EntityStatus } from "@/types/common"
import { WarrantyPeriod } from "@/types/master-data"
export const warrantyPeriodsApi = {
list(params: { status?: EntityStatus } = {}): Promise<WarrantyPeriod[]> {
return apiRequest<WarrantyPeriod[]>(`/warranty-periods${buildQuery(params)}`)
},
/** Months 1–600, not a default. Re-adding an inactive length reactivates it; an active duplicate is 409. */
create(months: number): Promise<WarrantyPeriod> {
return apiRequest<WarrantyPeriod>("/warranty-periods", { method: "POST", body: { months } })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(warrantyPeriodId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/warranty-periods/${warrantyPeriodId}/status`, { method: "PATCH", body: { status } })
},
}
@@ -84,7 +84,7 @@ export function validateTaxRate(code: string, name: string, percentage: number):
/** Renamed from validateVariantCategoryName — "variant categories" are Item Types now. */
export function validateItemTypeName(name: string): Record<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "Item type name is required"
if (!name.trim()) errors.name = "Item variant name is required"
return errors
}
+51
View File
@@ -0,0 +1,51 @@
// Warranty period helpers. Periods are always stored in months; custom ones are the active
// rows of the Warranty Periods master and are only offered while
// ProductConfig.customWarrantyPeriodsEnabled is on.
import { ProductConfig, WARRANTY_PERIOD_MONTHS_OPTIONS } from "@/types/master-data"
/** Server limit (WarrantyPeriodService) — mirrored so forms fail fast. */
export const MAX_CUSTOM_WARRANTY_MONTHS = 600
/** "6 months", "1 month", or "24 months (2 years)" when it's a whole number of years. */
export function formatWarrantyPeriod(months: number): string {
const base = `${months} month${months === 1 ? "" : "s"}`
if (months >= 12 && months % 12 === 0) {
const years = months / 12
return `${base} (${years} year${years === 1 ? "" : "s"})`
}
return base
}
export type WarrantyInputUnit = "months" | "years"
/**
* Validates a typed length ("2" + years) against the existing active custom periods and
* converts it to months. Returns the months, or an error message fit to show under the input.
*/
export function parseWarrantyInput(
amount: string,
unit: WarrantyInputUnit,
existingCustom: number[],
): { months: number; error?: undefined } | { months?: undefined; error: string } {
const n = Number(amount)
if (!amount.trim() || !Number.isInteger(n) || n < 1) return { error: "Enter a whole number of 1 or more." }
const months = unit === "years" ? n * 12 : n
if (months > MAX_CUSTOM_WARRANTY_MONTHS)
return { error: `A warranty period can be at most ${MAX_CUSTOM_WARRANTY_MONTHS} months (50 years).` }
if ((WARRANTY_PERIOD_MONTHS_OPTIONS as readonly number[]).includes(months))
return { error: `${formatWarrantyPeriod(months)} is already a default period.` }
if (existingCustom.includes(months)) return { error: `${formatWarrantyPeriod(months)} already exists.` }
return { months }
}
/** The period choices for an item: the defaults, plus the active custom ones when enabled. */
export function warrantyPeriodGroups(
config: ProductConfig | null,
customMonths: number[],
): { defaults: number[]; custom: number[] } {
const defaults: number[] = [...WARRANTY_PERIOD_MONTHS_OPTIONS]
const custom = config?.customWarrantyPeriodsEnabled
? [...new Set(customMonths)].filter((m) => !defaults.includes(m)).sort((a, b) => a - b)
: []
return { defaults, custom }
}
+37 -3
View File
@@ -11,9 +11,10 @@ export type StockNature = "Stocked" | "NonStocked" | "Service"
export type TrackingMode = "None" | "Batch" | "Serial"
export type Warranty = "NonWarranty" | "Warranty"
/** Warranty coverage lengths offered when an item is marked Warranty. */
/** Default warranty coverage lengths, always offered when an item is marked Warranty. */
export const WARRANTY_PERIOD_MONTHS_OPTIONS = [3, 6, 12, 18] as const
export type WarrantyPeriodMonths = (typeof WARRANTY_PERIOD_MONTHS_OPTIONS)[number]
/** Months — a default above or an active {@link WarrantyPeriod}. */
export type WarrantyPeriodMonths = number
/**
* Unit of an item's content size — how much one stocked pack holds.
@@ -364,11 +365,17 @@ export interface ProductConfig {
serialNumbersEnabled: boolean
batchListsEnabled: boolean
warrantyPeriodsEnabled: boolean
/** Advisory like `itemTypesEnabled`: lets item create add a new item variant inline. */
customVariantsEnabled: boolean
/** Server-enforced on upload (`CONFIG_DISABLED`); existing images stay viewable when off. */
itemImagesEnabled: boolean
/** Server-enforced on item writes: active {@link WarrantyPeriod} rows are only accepted while on. */
customWarrantyPeriodsEnabled: boolean
updatedAt: string | null
updatedBy: number | null
}
/** All three flags are required: an omitted flag is a 400, never a silent disable. */
/** Every flag is required: an omitted flag is a 400, never a silent disable. */
export interface UpdateProductConfigRequest {
subcategoriesEnabled: boolean
brandsEnabled: boolean
@@ -377,6 +384,33 @@ export interface UpdateProductConfigRequest {
serialNumbersEnabled: boolean
batchListsEnabled: boolean
warrantyPeriodsEnabled: boolean
customVariantsEnabled: boolean
itemImagesEnabled: boolean
customWarrantyPeriodsEnabled: boolean
}
/**
* Custom warranty period master (Products → Warranty Periods) — offered alongside
* `WARRANTY_PERIOD_MONTHS_OPTIONS` while `customWarrantyPeriodsEnabled`. Always months;
* items store the months, not a reference, so deactivating only stops it being offered.
*/
export interface WarrantyPeriod {
warrantyPeriodId: number
months: number
status: EntityStatus
createdAt: string
updatedAt: string | null
}
/** An item photo's metadata; the bytes come from `itemsApi.imageUrl`. */
export interface ItemImage {
itemImageId: number
itemId: number
fileName: string
contentType: string
sizeBytes: number
sortOrder: number
uploadedAt: string
}
// --- Barcode / RFID lot scanning --------------------------------------------------------
+45
View File
@@ -65,6 +65,51 @@ export interface SalesInvoicePostingIssue {
isFreeIssue: boolean
}
/** Sales configuration singleton. `cashCustomerId` is null until cash sales have been allowed once. */
export interface SalesConfig {
allowCashCustomer: boolean
cashCustomerId: number | null
cashCustomerName: string | null
/** When off, sales invoice lines can't carry a free qty (the Free column is hidden). */
allowFreeItems: boolean
/** When off, sales invoice lines can't carry a discount (the Disc % column is hidden). */
allowDiscounts: boolean
/** When on, sales post even when stock is short (on-hand goes negative); off blocks posting. */
allowOverStockSale: boolean
/** When off, New Sale hides the Warehouse picker and every sale uses `defaultWarehouseId`. */
allowWarehouseSelection: boolean
defaultWarehouseId: number | null
defaultWarehouseName: string | null
updatedAt: string | null
updatedBy: number | null
}
export interface UpdateSalesConfigRequest {
allowCashCustomer: boolean
allowFreeItems: boolean
allowDiscounts: boolean
allowOverStockSale: boolean
allowWarehouseSelection: boolean
/** Required when `allowWarehouseSelection` is false. */
defaultWarehouseId: number | null
}
/** Issuable stock for one item at one warehouse. `isStocked` false = non-stock item, never short. */
export interface SalesStockAvailability {
itemId: number
warehouseId: number
isStocked: boolean
available: number
}
export interface SalesSuggestedPrice {
itemId: number
warehouseId: number
unitPrice: number
/** SALE_PRICE | GRN_WEIGHTED_AVG | FIFO_AVG */
priceSource: string
}
export interface SalesInvoicePostingCheck {
salesInvoiceId: number
invoiceNo: string