feat: enhance product management features
- Implement sorting and filtering for categories in the dashboard. - Add status filtering and sorting capabilities for categories. - Introduce warehouse selection and base UOM configuration in the new item creation page. - Fetch and display brands and subcategories in the items page. - Update product settings to manage subcategories and brands. - Refactor sidebar navigation to include configuration options.
This commit is contained in:
@@ -3,17 +3,16 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
|
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
import { validateItemForm } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data"
|
import { Item, StockNature, TrackingMode } from "@/types/master-data"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -21,30 +20,9 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
interface ReorderDraft {
|
|
||||||
key: string
|
|
||||||
warehouseId: number | null
|
|
||||||
reorderPoint: string
|
|
||||||
reorderQty: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConversionDraft {
|
|
||||||
key: string
|
|
||||||
fromUom: number | null
|
|
||||||
toUom: number | null
|
|
||||||
factor: string
|
|
||||||
}
|
|
||||||
|
|
||||||
let keySeq = 0
|
|
||||||
function newKey() {
|
|
||||||
keySeq += 1
|
|
||||||
return `row-${keySeq}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ItemDetailPage() {
|
export default function ItemDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -54,13 +32,13 @@ export default function ItemDetailPage() {
|
|||||||
const [etag, setEtag] = useState<string | null>(null)
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
|
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
|
||||||
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||||
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([])
|
|
||||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Basic info form
|
// Basic info form
|
||||||
const [sku, setSku] = useState("")
|
const [sku, setSku] = useState("")
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
|
// No longer editable here — carried through unchanged so a save doesn't silently clear it.
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||||
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
|
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
|
||||||
@@ -68,10 +46,15 @@ export default function ItemDetailPage() {
|
|||||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||||
const [brandId, setBrandId] = useState<number | null>(null)
|
const [brandId, setBrandId] = useState<number | null>(null)
|
||||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
|
||||||
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||||
|
// Default vendor, tax class, and tracking mode are no longer editable on this page —
|
||||||
|
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
|
||||||
|
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||||
const [taxClass, setTaxClass] = useState("")
|
const [taxClass, setTaxClass] = useState("")
|
||||||
|
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
|
||||||
|
// isn't sent on save — nothing to wire it to server-side.
|
||||||
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [conflict, setConflict] = useState(false)
|
const [conflict, setConflict] = useState(false)
|
||||||
@@ -79,18 +62,6 @@ export default function ItemDetailPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||||
|
|
||||||
// Reorder settings
|
|
||||||
const [reorderLines, setReorderLines] = useState<ReorderDraft[]>([])
|
|
||||||
const [reorderErrors, setReorderErrors] = useState<Record<string, Record<string, string>>>({})
|
|
||||||
const [reorderSaveError, setReorderSaveError] = useState<string | null>(null)
|
|
||||||
const [savingReorder, setSavingReorder] = useState(false)
|
|
||||||
|
|
||||||
// UOM conversions
|
|
||||||
const [conversionLines, setConversionLines] = useState<ConversionDraft[]>([])
|
|
||||||
const [conversionErrors, setConversionErrors] = useState<Record<string, Record<string, string>>>({})
|
|
||||||
const [conversionSaveError, setConversionSaveError] = useState<string | null>(null)
|
|
||||||
const [savingConversions, setSavingConversions] = useState(false)
|
|
||||||
|
|
||||||
function applyItem(data: Item) {
|
function applyItem(data: Item) {
|
||||||
setItem(data)
|
setItem(data)
|
||||||
setSku(data.sku)
|
setSku(data.sku)
|
||||||
@@ -104,8 +75,6 @@ export default function ItemDetailPage() {
|
|||||||
setStockNature(data.stockNature)
|
setStockNature(data.stockNature)
|
||||||
setTrackingMode(data.trackingMode)
|
setTrackingMode(data.trackingMode)
|
||||||
setTaxClass(data.taxClass ?? "")
|
setTaxClass(data.taxClass ?? "")
|
||||||
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
|
|
||||||
setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
@@ -123,11 +92,10 @@ export default function ItemDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(itemId)) return
|
if (!Number.isFinite(itemId)) return
|
||||||
load()
|
load()
|
||||||
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })])
|
||||||
.then(([cat, uo, ve, wh]) => {
|
.then(([cat, uo, wh]) => {
|
||||||
setCategories(cat.items)
|
setCategories(cat.items)
|
||||||
setUoms(uo.items)
|
setUoms(uo.items)
|
||||||
setVendors(ve.items)
|
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -183,83 +151,6 @@ export default function ItemDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateReorderLine(key: string, patch: Partial<ReorderDraft>) {
|
|
||||||
setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
|
||||||
}
|
|
||||||
function removeReorderLine(key: string) {
|
|
||||||
setReorderLines((prev) => prev.filter((l) => l.key !== key))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveReorder() {
|
|
||||||
if (!item) return
|
|
||||||
setReorderSaveError(null)
|
|
||||||
const nextErrors: Record<string, Record<string, string>> = {}
|
|
||||||
for (const line of reorderLines) {
|
|
||||||
const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty })
|
|
||||||
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
|
|
||||||
}
|
|
||||||
setReorderErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) {
|
|
||||||
setReorderSaveError("Fix the highlighted rows before saving.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings: ItemReorderSetting[] = reorderLines.map((l) => ({
|
|
||||||
warehouseId: l.warehouseId as number,
|
|
||||||
reorderPoint: Number(l.reorderPoint),
|
|
||||||
reorderQty: Number(l.reorderQty),
|
|
||||||
}))
|
|
||||||
|
|
||||||
setSavingReorder(true)
|
|
||||||
try {
|
|
||||||
const result = await itemsApi.updateReorder(item.itemId, { settings })
|
|
||||||
setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev))
|
|
||||||
toast.success("Reorder settings saved")
|
|
||||||
} catch (err) {
|
|
||||||
setReorderSaveError(errorMessage(err))
|
|
||||||
toast.error("Could not save reorder settings", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSavingReorder(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateConversionLine(key: string, patch: Partial<ConversionDraft>) {
|
|
||||||
setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
|
||||||
}
|
|
||||||
function removeConversionLine(key: string) {
|
|
||||||
setConversionLines((prev) => prev.filter((l) => l.key !== key))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveConversions() {
|
|
||||||
if (!item) return
|
|
||||||
setConversionSaveError(null)
|
|
||||||
const nextErrors: Record<string, Record<string, string>> = {}
|
|
||||||
for (const line of conversionLines) {
|
|
||||||
const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor })
|
|
||||||
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
|
|
||||||
}
|
|
||||||
setConversionErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) {
|
|
||||||
setConversionSaveError("Fix the highlighted rows before saving.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) }))
|
|
||||||
|
|
||||||
setSavingConversions(true)
|
|
||||||
try {
|
|
||||||
const result = await itemsApi.updateUomConversions(item.itemId, { conversions })
|
|
||||||
setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev))
|
|
||||||
setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
|
|
||||||
toast.success("UOM conversions saved")
|
|
||||||
} catch (err) {
|
|
||||||
setConversionSaveError(errorMessage(err))
|
|
||||||
toast.error("Could not save UOM conversions", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSavingConversions(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function uomName(uomId: number) {
|
function uomName(uomId: number) {
|
||||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||||
}
|
}
|
||||||
@@ -339,13 +230,14 @@ export default function ItemDetailPage() {
|
|||||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
|
||||||
<Label className="text-base">Description</Label>
|
|
||||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Category</Label>
|
<Label className="text-base">Category</Label>
|
||||||
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict}>
|
<Select<number | null>
|
||||||
|
value={categoryId}
|
||||||
|
onValueChange={setCategoryId}
|
||||||
|
disabled={conflict}
|
||||||
|
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||||
<SelectValue placeholder="Select category" />
|
<SelectValue placeholder="Select category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -361,7 +253,12 @@ export default function ItemDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Base UOM</Label>
|
<Label className="text-base">Base UOM</Label>
|
||||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}>
|
<Select<number | null>
|
||||||
|
value={baseUomId}
|
||||||
|
onValueChange={setBaseUomId}
|
||||||
|
disabled={conflict}
|
||||||
|
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
|
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
|
||||||
<SelectValue placeholder="Select base UOM" />
|
<SelectValue placeholder="Select base UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -375,25 +272,6 @@ export default function ItemDetailPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Default vendor</Label>
|
|
||||||
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}>
|
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
|
||||||
<SelectValue placeholder="None" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{vendors.map((v) => (
|
|
||||||
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
|
|
||||||
{v.code} — {v.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-base">Tax class</Label>
|
|
||||||
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* "Item type" now means a Color/Size dimension master — this field is the
|
{/* "Item type" now means a Color/Size dimension master — this field is the
|
||||||
stock-nature one it used to be confused with (docs/11 §8). */}
|
stock-nature one it used to be confused with (docs/11 §8). */}
|
||||||
@@ -410,15 +288,22 @@ export default function ItemDetailPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Tracking mode</Label>
|
<Label className="text-base">Warehouse (optional)</Label>
|
||||||
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}>
|
<Select<number | null>
|
||||||
|
value={warehouseId}
|
||||||
|
onValueChange={setWarehouseId}
|
||||||
|
disabled={conflict}
|
||||||
|
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue />
|
<SelectValue placeholder="Select warehouse" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="None" className="text-base">None</SelectItem>
|
{warehouses.map((w) => (
|
||||||
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||||
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
|
{w.code} — {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,165 +319,8 @@ export default function ItemDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold text-foreground">Reorder settings</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Per-warehouse reorder point and quantity (FR-MD-05).</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setReorderLines((prev) => [...prev, { key: newKey(), warehouseId: null, reorderPoint: "", reorderQty: "" }])}>
|
|
||||||
<Plus className="size-5" />
|
|
||||||
Add row
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reorderLines.length > 0 && (
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Reorder qty</TableHead>
|
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{reorderLines.map((line) => {
|
|
||||||
const errs = reorderErrors[line.key] ?? {}
|
|
||||||
return (
|
|
||||||
<TableRow key={line.key}>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.warehouseId}>
|
|
||||||
<SelectValue placeholder="Warehouse" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{warehouses.map((w) => (
|
|
||||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
|
||||||
{w.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errs.warehouseId ? { message: errs.warehouseId } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input type="number" min="0" step="any" value={line.reorderPoint} aria-invalid={!!errs.reorderPoint} onChange={(e) => updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" />
|
|
||||||
<FieldError errors={[errs.reorderPoint ? { message: errs.reorderPoint } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input type="number" min="0" step="any" value={line.reorderQty} aria-invalid={!!errs.reorderQty} onChange={(e) => updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" />
|
|
||||||
<FieldError errors={[errs.reorderQty ? { message: errs.reorderQty } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeReorderLine(line.key)} aria-label="Remove row">
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reorderSaveError && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{reorderSaveError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="button" onClick={handleSaveReorder} disabled={savingReorder}>
|
|
||||||
{savingReorder ? "Saving…" : "Save reorder settings"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold text-foreground">UOM conversions</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setConversionLines((prev) => [...prev, { key: newKey(), fromUom: null, toUom: item.baseUomId, factor: "" }])}>
|
|
||||||
<Plus className="size-5" />
|
|
||||||
Add row
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{conversionLines.length > 0 && (
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">From UOM</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">To UOM</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Factor</TableHead>
|
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{conversionLines.map((line) => {
|
|
||||||
const errs = conversionErrors[line.key] ?? {}
|
|
||||||
return (
|
|
||||||
<TableRow key={line.key}>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Select<number | null> value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.fromUom}>
|
|
||||||
<SelectValue placeholder="From" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{uoms.map((u) => (
|
|
||||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
|
||||||
{u.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errs.fromUom ? { message: errs.fromUom } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Select<number | null> value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.toUom}>
|
|
||||||
<SelectValue placeholder="To" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{uoms.map((u) => (
|
|
||||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
|
||||||
{u.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errs.toUom ? { message: errs.toUom } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input type="number" min="0" step="any" value={line.factor} aria-invalid={!!errs.factor} onChange={(e) => updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" />
|
|
||||||
<FieldError errors={[errs.factor ? { message: errs.factor } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeConversionLine(line.key)} aria-label="Remove row">
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{conversionSaveError && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{conversionSaveError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="button" onClick={handleSaveConversions} disabled={savingConversions}>
|
|
||||||
{savingConversions ? "Saving…" : "Save conversions"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."}
|
{uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
||||||
|
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateBrandName } from "@/lib/validations/master-data"
|
import { validateBrandName } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Brand } from "@/types/master-data"
|
import { Brand } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
@@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
type SortOrder = "asc" | "desc"
|
type SortOrder = "asc" | "desc"
|
||||||
|
type SortKey = "brandId" | "name" | "status" | "createdAt"
|
||||||
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 5
|
const PAGE_SIZE = 5
|
||||||
|
|
||||||
@@ -33,8 +35,12 @@ export default function BrandsPage() {
|
|||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
const [searchInput, setSearchInput] = useState("")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
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")
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Brand | null>(null)
|
const [editing, setEditing] = useState<Brand | null>(null)
|
||||||
@@ -50,12 +56,12 @@ export default function BrandsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [search, sortOrder])
|
}, [search, status])
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
brandsApi
|
brandsApi
|
||||||
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
|
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setBrands(res.items)
|
setBrands(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -63,9 +69,30 @@ export default function BrandsPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [search, sortOrder, page])
|
useEffect(load, [search, status, page])
|
||||||
|
|
||||||
const hasFilters = search.trim().length > 0
|
const hasFilters = search.trim().length > 0 || status !== "All"
|
||||||
|
|
||||||
|
const sortedBrands = brands
|
||||||
|
? [...brands].sort((a, b) => {
|
||||||
|
const cmp =
|
||||||
|
sortKey === "brandId"
|
||||||
|
? a.brandId - b.brandId
|
||||||
|
: 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() {
|
function openCreateDialog() {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -176,13 +203,14 @@ export default function BrandsPage() {
|
|||||||
aria-label="Search brands"
|
aria-label="Search brands"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}>
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="asc" className="text-base">Name (A–Z)</SelectItem>
|
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||||
<SelectItem value="desc" className="text-base">Name (Z–A)</SelectItem>
|
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||||
|
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,7 +231,7 @@ export default function BrandsPage() {
|
|||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<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" />
|
<Tag className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
{hasFilters ? "No brands match your search." : "No brands yet."}
|
{hasFilters ? "No brands match your search/filter." : "No brands yet."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -213,15 +241,23 @@ export default function BrandsPage() {
|
|||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader className="bg-indigo-50">
|
||||||
<TableRow className="hover:bg-indigo-50">
|
<TableRow className="hover:bg-indigo-50">
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
|
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
|
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
|
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
|
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||||
|
</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{brands.map((b) => (
|
{sortedBrands!.map((b) => (
|
||||||
<TableRow key={b.brandId}>
|
<TableRow key={b.brandId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||||
@@ -313,3 +349,35 @@ export default function BrandsPage() {
|
|||||||
</div>
|
</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-indigo-900"
|
||||||
|
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-indigo-700/40" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||||
|
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Category } from "@/types/master-data"
|
import { Category } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
@@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
type SortOrder = "asc" | "desc"
|
type SortOrder = "asc" | "desc"
|
||||||
|
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||||
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 5
|
const PAGE_SIZE = 5
|
||||||
|
|
||||||
@@ -33,8 +35,12 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
const [searchInput, setSearchInput] = useState("")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
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")
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Category | null>(null)
|
const [editing, setEditing] = useState<Category | null>(null)
|
||||||
@@ -50,12 +56,12 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [search, sortOrder])
|
}, [search, status])
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
categoriesApi
|
categoriesApi
|
||||||
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
|
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setCategories(res.items)
|
setCategories(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -63,9 +69,30 @@ export default function CategoriesPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [search, sortOrder, page])
|
useEffect(load, [search, status, page])
|
||||||
|
|
||||||
const hasFilters = search.trim().length > 0
|
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() {
|
function openCreateDialog() {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -175,13 +202,14 @@ export default function CategoriesPage() {
|
|||||||
aria-label="Search categories"
|
aria-label="Search categories"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}>
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="asc" className="text-base">Name (A–Z)</SelectItem>
|
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||||
<SelectItem value="desc" className="text-base">Name (Z–A)</SelectItem>
|
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
||||||
|
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +230,7 @@ export default function CategoriesPage() {
|
|||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<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" />
|
<ListTree className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
{hasFilters ? "No categories match your search." : "No categories yet."}
|
{hasFilters ? "No categories match your search/filter." : "No categories yet."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -212,15 +240,23 @@ export default function CategoriesPage() {
|
|||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader className="bg-indigo-50">
|
||||||
<TableRow className="hover:bg-indigo-50">
|
<TableRow className="hover:bg-indigo-50">
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
|
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
|
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
|
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
||||||
|
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||||
|
</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{categories.map((c) => (
|
{sortedCategories!.map((c) => (
|
||||||
<TableRow key={c.categoryId}>
|
<TableRow key={c.categoryId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||||
@@ -320,3 +356,35 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</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-indigo-900"
|
||||||
|
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-indigo-700/40" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { brandsApi } from "@/lib/api/brands"
|
|||||||
import { itemTypesApi } from "@/lib/api/item-types"
|
import { itemTypesApi } from "@/lib/api/item-types"
|
||||||
import { productConfig } from "@/lib/api/product-config"
|
import { productConfig } from "@/lib/api/product-config"
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data"
|
import { validateVariantItemForm } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data"
|
import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -36,26 +37,11 @@ function buildVariantSku(categoryLabel: string, values: string[]): string {
|
|||||||
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
|
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The item builder only ever offers these two dimensions, regardless of what else exists
|
||||||
* Colour is special-cased by name. This stays a frontend concern: item types are names
|
* in the Item Types master list. */
|
||||||
* only — there is no value table server-side to hang a hex column off (docs/10 Part C.9).
|
const BUILDER_ITEM_TYPES = ["color", "size"]
|
||||||
*/
|
function isBuilderItemType(name: string): boolean {
|
||||||
function isColorCategory(categoryName: string): boolean {
|
return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
|
||||||
return categoryName.trim().toLowerCase() === "color"
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeColorValue(name: string, hex: string): string {
|
|
||||||
return `${name}|${hex}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeColorValue(value: string): { name: string; hex: string } {
|
|
||||||
const separatorIndex = value.indexOf("|")
|
|
||||||
if (separatorIndex === -1) return { name: value, hex: "#d4d4d8" }
|
|
||||||
return { name: value.slice(0, separatorIndex), hex: value.slice(separatorIndex + 1) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function partLabel(part: { name: string; value: string }): string {
|
|
||||||
return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewItemPage() {
|
export default function NewItemPage() {
|
||||||
@@ -65,29 +51,27 @@ export default function NewItemPage() {
|
|||||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||||
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
|
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
|
||||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||||
/**
|
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
|
||||||
* This form has no Base UOM field by design, so it adopts the first UOM as the base.
|
/** Defaults to the first UOM once loaded; null only means none exist yet. */
|
||||||
* It used to hardcode `uomId: 1`, which only worked because the mock seeded that id —
|
|
||||||
* against a real database that is a 422 waiting to happen, or worse, silently the wrong
|
|
||||||
* unit. Null here means "no UOM exists yet" and the form says so rather than guessing.
|
|
||||||
*/
|
|
||||||
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
const [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||||
|
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||||
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||||
const [brandId, setBrandId] = useState<number | null>(null)
|
const [brandId, setBrandId] = useState<number | null>(null)
|
||||||
|
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
|
||||||
|
// isn't sent on submit — nothing to wire it to server-side.
|
||||||
|
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||||
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
|
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
|
||||||
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
|
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
|
||||||
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
||||||
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
|
// Lets a specific generated combination be dropped from the preview table before
|
||||||
|
// submit, without having to remove and re-add the whole value that produced it.
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
|
||||||
const [newCategoryName, setNewCategoryName] = useState("")
|
|
||||||
const [newCategoryError, setNewCategoryError] = useState<string | null>(null)
|
|
||||||
const [addingCategorySubmitting, setAddingCategorySubmitting] = useState(false)
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
@@ -99,14 +83,17 @@ export default function NewItemPage() {
|
|||||||
brandsApi.list({ pageSize: 200, status: "Active" }),
|
brandsApi.list({ pageSize: 200, status: "Active" }),
|
||||||
itemTypesApi.list({ pageSize: 200, status: "Active" }),
|
itemTypesApi.list({ pageSize: 200, status: "Active" }),
|
||||||
productConfig(),
|
productConfig(),
|
||||||
uomsApi.list({ pageSize: 1 }),
|
uomsApi.list({ pageSize: 200 }),
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
])
|
])
|
||||||
.then(([cat, br, types, cfg, uoms]) => {
|
.then(([cat, br, types, cfg, uoms, wh]) => {
|
||||||
setCategories(cat.items)
|
setCategories(cat.items)
|
||||||
setBrands(br.items)
|
setBrands(br.items)
|
||||||
setItemTypes(types.items)
|
setItemTypes(types.items)
|
||||||
setConfig(cfg)
|
setConfig(cfg)
|
||||||
|
setUoms(uoms.items)
|
||||||
setBaseUomId(uoms.items[0]?.uomId ?? null)
|
setBaseUomId(uoms.items[0]?.uomId ?? null)
|
||||||
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -141,30 +128,8 @@ export default function NewItemPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddItemType() {
|
function addValue(itemTypeId: number) {
|
||||||
const nextErrors = validateItemTypeName(newCategoryName)
|
const value = (inputByCategory[itemTypeId] ?? "").trim()
|
||||||
if (nextErrors.name) {
|
|
||||||
setNewCategoryError(nextErrors.name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAddingCategorySubmitting(true)
|
|
||||||
try {
|
|
||||||
const created = await itemTypesApi.create({ name: newCategoryName })
|
|
||||||
setItemTypes((prev) => [...(prev ?? []), created.data])
|
|
||||||
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
|
|
||||||
setNewCategoryName("")
|
|
||||||
setNewCategoryError(null)
|
|
||||||
setAddingCategory(false)
|
|
||||||
toast.success("Item type created", created.data.name)
|
|
||||||
} catch (err) {
|
|
||||||
setNewCategoryError(errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setAddingCategorySubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addValue(itemTypeId: number, overrideValue?: string) {
|
|
||||||
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
|
|
||||||
if (value) {
|
if (value) {
|
||||||
setValuesByCategory((prev) => {
|
setValuesByCategory((prev) => {
|
||||||
const existing = prev[itemTypeId] ?? []
|
const existing = prev[itemTypeId] ?? []
|
||||||
@@ -191,7 +156,7 @@ export default function NewItemPage() {
|
|||||||
[itemTypes, checkedItemTypeIds, valuesByCategory]
|
[itemTypes, checkedItemTypeIds, valuesByCategory]
|
||||||
)
|
)
|
||||||
|
|
||||||
const variants = useMemo(() => {
|
const allVariants = useMemo(() => {
|
||||||
if (activeCategories.length === 0) return []
|
if (activeCategories.length === 0) return []
|
||||||
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
|
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
|
||||||
for (const cat of activeCategories) {
|
for (const cat of activeCategories) {
|
||||||
@@ -208,10 +173,19 @@ export default function NewItemPage() {
|
|||||||
}
|
}
|
||||||
return combinations.map((c) => ({
|
return combinations.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
|
sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)),
|
||||||
}))
|
}))
|
||||||
}, [activeCategories, effectiveLabel])
|
}, [activeCategories, effectiveLabel])
|
||||||
|
|
||||||
|
const variants = useMemo(
|
||||||
|
() => allVariants.filter((v) => !removedVariantKeys.has(v.key)),
|
||||||
|
[allVariants, removedVariantKeys]
|
||||||
|
)
|
||||||
|
|
||||||
|
function removeVariant(key: string) {
|
||||||
|
setRemovedVariantKeys((prev) => new Set(prev).add(key))
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
|
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
|
||||||
@@ -228,7 +202,7 @@ export default function NewItemPage() {
|
|||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
await itemsApi.create({
|
await itemsApi.create({
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
|
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`,
|
||||||
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
|
||||||
// category, which lost the parent entirely. The server rejects a mismatched
|
// category, which lost the parent entirely. The server rejects a mismatched
|
||||||
// pair with 422.
|
// pair with 422.
|
||||||
@@ -236,7 +210,7 @@ export default function NewItemPage() {
|
|||||||
subCategoryId,
|
subCategoryId,
|
||||||
brandId,
|
brandId,
|
||||||
baseUomId,
|
baseUomId,
|
||||||
stockNature: "Stocked",
|
stockNature,
|
||||||
trackingMode: "None",
|
trackingMode: "None",
|
||||||
})
|
})
|
||||||
created += 1
|
created += 1
|
||||||
@@ -294,7 +268,11 @@ export default function NewItemPage() {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Category</Label>
|
<Label className="text-base">Category</Label>
|
||||||
<Select<number | null> value={categoryId} onValueChange={handleCategoryChange}>
|
<Select<number | null>
|
||||||
|
value={categoryId}
|
||||||
|
onValueChange={handleCategoryChange}
|
||||||
|
items={(categories ?? []).map((c) => ({ label: c.name, value: c.categoryId }))}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||||
<SelectValue placeholder="Select category" />
|
<SelectValue placeholder="Select category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -313,7 +291,12 @@ export default function NewItemPage() {
|
|||||||
{config?.subcategoriesEnabled && (
|
{config?.subcategoriesEnabled && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Subcategory (optional)</Label>
|
<Label className="text-base">Subcategory (optional)</Label>
|
||||||
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
|
<Select<number | null>
|
||||||
|
value={subCategoryId}
|
||||||
|
onValueChange={setSubCategoryId}
|
||||||
|
disabled={subCategories.length === 0}
|
||||||
|
items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
|
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -330,7 +313,11 @@ export default function NewItemPage() {
|
|||||||
{config?.brandsEnabled && (
|
{config?.brandsEnabled && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Brand (optional)</Label>
|
<Label className="text-base">Brand (optional)</Label>
|
||||||
<Select<number | null> value={brandId} onValueChange={setBrandId}>
|
<Select<number | null>
|
||||||
|
value={brandId}
|
||||||
|
onValueChange={setBrandId}
|
||||||
|
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder="Select brand" />
|
<SelectValue placeholder="Select brand" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -344,6 +331,57 @@ export default function NewItemPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Warehouse (optional)</Label>
|
||||||
|
<Select<number | null>
|
||||||
|
value={warehouseId}
|
||||||
|
onValueChange={setWarehouseId}
|
||||||
|
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select warehouse" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehouses.map((w) => (
|
||||||
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||||
|
{w.code} — {w.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Base UOM</Label>
|
||||||
|
<Select<number | null>
|
||||||
|
value={baseUomId}
|
||||||
|
onValueChange={setBaseUomId}
|
||||||
|
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select base UOM" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Stock nature</Label>
|
||||||
|
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
|
||||||
|
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
|
||||||
|
<SelectItem value="Service" className="text-base">Service</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||||
@@ -358,67 +396,19 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{(itemTypes ?? []).map((t) => (
|
{(itemTypes ?? [])
|
||||||
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
.filter((t) => isBuilderItemType(t.name))
|
||||||
<Checkbox
|
.map((t) => (
|
||||||
checked={checkedItemTypeIds.includes(t.itemTypeId)}
|
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
||||||
onCheckedChange={() => toggleItemType(t.itemTypeId)}
|
<Checkbox
|
||||||
/>
|
checked={checkedItemTypeIds.includes(t.itemTypeId)}
|
||||||
<span className="text-base font-medium">{t.name}</span>
|
onCheckedChange={() => toggleItemType(t.itemTypeId)}
|
||||||
</label>
|
/>
|
||||||
))}
|
<span className="text-base font-medium">{t.name}</span>
|
||||||
{!addingCategory && (
|
</label>
|
||||||
<Button
|
))}
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Add another item type"
|
|
||||||
onClick={() => setAddingCategory(true)}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addingCategory && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
handleAddItemType()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Material"
|
|
||||||
className="h-11 max-w-xs text-base"
|
|
||||||
aria-invalid={!!newCategoryError}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Cancel"
|
|
||||||
onClick={() => {
|
|
||||||
setAddingCategory(false)
|
|
||||||
setNewCategoryName("")
|
|
||||||
setNewCategoryError(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<FieldError errors={[newCategoryError ? { message: newCategoryError } : undefined]} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
|
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
|
||||||
|
|
||||||
{checkedItemTypeIds.length > 0 && (
|
{checkedItemTypeIds.length > 0 && (
|
||||||
@@ -426,86 +416,43 @@ export default function NewItemPage() {
|
|||||||
{(itemTypes ?? [])
|
{(itemTypes ?? [])
|
||||||
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
|
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
const isColor = isColorCategory(t.name)
|
|
||||||
const currentInput = inputByCategory[t.itemTypeId] ?? ""
|
const currentInput = inputByCategory[t.itemTypeId] ?? ""
|
||||||
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
|
|
||||||
|
|
||||||
function addColor() {
|
|
||||||
const name = currentColorName.trim()
|
|
||||||
if (!name) return
|
|
||||||
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
|
|
||||||
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={t.itemTypeId} className="flex flex-col gap-2">
|
<div key={t.itemTypeId} className="flex flex-col gap-2">
|
||||||
<Label className="text-base">{t.name} values</Label>
|
<Label className="text-base">{t.name} values</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isColor ? (
|
<Input
|
||||||
<>
|
value={currentInput}
|
||||||
<input
|
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||||
type="color"
|
onKeyDown={(e) => {
|
||||||
value={currentInput || "#EF4444"}
|
if (e.key === "Enter") {
|
||||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
e.preventDefault()
|
||||||
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5"
|
addValue(t.itemTypeId)
|
||||||
aria-label="Pick color"
|
}
|
||||||
/>
|
}}
|
||||||
<Input
|
placeholder={t.name}
|
||||||
value={currentColorName}
|
className="h-11 text-base"
|
||||||
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
/>
|
||||||
onKeyDown={(e) => {
|
<Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
addColor()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Color name (e.g. Red)"
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={currentInput}
|
|
||||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
addValue(t.itemTypeId)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t.name}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(t.itemTypeId))}>
|
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add {t.name}
|
Add {t.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
|
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => (
|
||||||
const decoded = isColor ? decodeColorValue(v) : null
|
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
||||||
return (
|
{v}
|
||||||
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
<button
|
||||||
{decoded && (
|
type="button"
|
||||||
<span
|
onClick={() => removeValue(t.itemTypeId, v)}
|
||||||
className="size-3.5 shrink-0 rounded-full border border-black/10"
|
className="rounded-full p-0.5 hover:bg-muted"
|
||||||
style={{ backgroundColor: decoded.hex }}
|
aria-label={`Remove ${v}`}
|
||||||
aria-hidden="true"
|
>
|
||||||
/>
|
<X className="size-3" />
|
||||||
)}
|
</button>
|
||||||
{decoded ? decoded.name : v}
|
</Badge>
|
||||||
<button
|
))}
|
||||||
type="button"
|
|
||||||
onClick={() => removeValue(t.itemTypeId, v)}
|
|
||||||
className="rounded-full p-0.5 hover:bg-muted"
|
|
||||||
aria-label={`Remove ${decoded ? decoded.name : v}`}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -526,29 +473,30 @@ export default function NewItemPage() {
|
|||||||
Item contract and no initial-receipt flow — stock arrives via a GRN.
|
Item contract and no initial-receipt flow — stock arrives via a GRN.
|
||||||
The input was informational-only under the mock and would now be a
|
The input was informational-only under the mock and would now be a
|
||||||
field that silently discards what you type. */}
|
field that silently discards what you type. */}
|
||||||
|
<TableHead className="h-11 w-8 px-1" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{variants.map((variant) => (
|
{variants.map((variant) => (
|
||||||
<TableRow key={variant.key}>
|
<TableRow key={variant.key}>
|
||||||
{variant.parts.map((part, i) => {
|
{variant.parts.map((part, i) => (
|
||||||
const decoded = isColorCategory(part.name) ? decodeColorValue(part.value) : null
|
<TableCell key={i} className="px-3 py-2.5">
|
||||||
return (
|
{part.value}
|
||||||
<TableCell key={i} className="px-3 py-2.5">
|
</TableCell>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
))}
|
||||||
{decoded && (
|
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
||||||
<span
|
<TableCell className="py-2.5 pr-3 pl-0">
|
||||||
className="size-3.5 shrink-0 rounded-full border border-black/10"
|
<Button
|
||||||
style={{ backgroundColor: decoded.hex }}
|
type="button"
|
||||||
aria-hidden="true"
|
variant="ghost"
|
||||||
/>
|
size="icon-sm"
|
||||||
)}
|
onClick={() => removeVariant(variant.key)}
|
||||||
{partLabel(part)}
|
aria-label={`Remove ${variant.sku}`}
|
||||||
</span>
|
className="text-destructive hover:bg-destructive/10"
|
||||||
</TableCell>
|
>
|
||||||
)
|
<X className="size-4" />
|
||||||
})}
|
</Button>
|
||||||
<TableCell className="px-3 py-2.5 font-medium">{variant.sku}</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Sear
|
|||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
|
import { productConfig } from "@/lib/api/product-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Category, ItemListItem, TrackingMode } from "@/types/master-data"
|
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -26,6 +28,9 @@ const PAGE_SIZE = 10
|
|||||||
export default function ItemsPage() {
|
export default function ItemsPage() {
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||||
|
const [brands, setBrands] = useState<Brand[]>([])
|
||||||
|
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -64,12 +69,33 @@ export default function ItemsPage() {
|
|||||||
useEffect(load, [page, query, status, categoryId, trackingMode])
|
useEffect(load, [page, query, status, categoryId, trackingMode])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
|
||||||
|
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
|
||||||
|
productConfig().then(setConfig).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// No "list all subcategories" endpoint exists — they're scoped per category — so once
|
||||||
|
// categories are in, fetch each one's subcategories in parallel to build a flat lookup.
|
||||||
|
useEffect(() => {
|
||||||
|
if (categories.length === 0) return
|
||||||
|
Promise.all(
|
||||||
|
categories.map((c) =>
|
||||||
|
categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] }))
|
||||||
|
)
|
||||||
|
).then((results) => setSubCategories(results.flatMap((r) => r.items)))
|
||||||
|
}, [categories])
|
||||||
|
|
||||||
function categoryName(id: number) {
|
function categoryName(id: number) {
|
||||||
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
|
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subCategoryName(id: number) {
|
||||||
|
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandName(id: number) {
|
||||||
|
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
|
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,7 +132,11 @@ export default function ItemsPage() {
|
|||||||
aria-label="Search items"
|
aria-label="Search items"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select<number | "All"> value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}>
|
<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">
|
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||||
<SelectValue placeholder="All categories" />
|
<SelectValue placeholder="All categories" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -175,6 +205,8 @@ export default function ItemsPage() {
|
|||||||
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
|
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
|
||||||
|
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
|
||||||
|
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
@@ -191,6 +223,16 @@ export default function ItemsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
|
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
|
||||||
|
{config?.subcategoriesEnabled && (
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
|
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{config?.brandsEnabled && (
|
||||||
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||||
|
{item.brandId !== null ? brandName(item.brandId) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
|
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
|
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Info } from "lucide-react"
|
import { ArrowLeft, Package } from "lucide-react"
|
||||||
|
|
||||||
import { productConfigApi } from "@/lib/api/product-config"
|
import { productConfigApi } from "@/lib/api/product-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -17,11 +17,10 @@ import { toast } from "@/components/ui/toast"
|
|||||||
/**
|
/**
|
||||||
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
|
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
|
||||||
*
|
*
|
||||||
* Only three flags exist. `subcategoriesEnabled`/`brandsEnabled` are enforced by the
|
* `subcategoriesEnabled`/`brandsEnabled` are the only user-toggleable flags here and are
|
||||||
* server (an item write carrying a gated field gets 422 CONFIG_DISABLED);
|
* enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED).
|
||||||
* `itemTypesEnabled` is advisory — items hold no item-type reference, so the frontend
|
* `itemTypesEnabled` has no control on this screen but is still part of the record, so
|
||||||
* hiding the builder's type section IS the enforcement. That distinction is surfaced in
|
* every save round-trips its current value unchanged (the server rejects a partial body).
|
||||||
* the UI rather than hidden, because it changes what "off" actually guarantees.
|
|
||||||
*/
|
*/
|
||||||
export default function ProductSettingsPage() {
|
export default function ProductSettingsPage() {
|
||||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||||
@@ -42,7 +41,7 @@ export default function ProductSettingsPage() {
|
|||||||
|
|
||||||
useEffect(load, [])
|
useEffect(load, [])
|
||||||
|
|
||||||
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) {
|
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
setSaving(flag)
|
setSaving(flag)
|
||||||
try {
|
try {
|
||||||
@@ -90,32 +89,29 @@ export default function ProductSettingsPage() {
|
|||||||
|
|
||||||
{!error && config && (
|
{!error && config && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-6">
|
<div className="flex flex-col gap-4 rounded-xl border p-6">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
|
||||||
|
</div>
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
<ToggleRow
|
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
|
||||||
label="Subcategories"
|
<ToggleRow
|
||||||
description="Adds one optional level below a category. Off ⇒ items attach directly to a category."
|
label="Subcategories"
|
||||||
checked={config.subcategoriesEnabled}
|
description="Category hierarchy includes a subcategory level. Off ⇒ products attach directly to a category."
|
||||||
busy={saving === "subcategoriesEnabled"}
|
checked={config.subcategoriesEnabled}
|
||||||
onChange={(v) => toggle("subcategoriesEnabled", v)}
|
busy={saving === "subcategoriesEnabled"}
|
||||||
/>
|
onChange={(v) => toggle("subcategoriesEnabled", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Brands"
|
label="Brands"
|
||||||
description="Items may carry a brand."
|
description="Products may carry a brand."
|
||||||
checked={config.brandsEnabled}
|
checked={config.brandsEnabled}
|
||||||
busy={saving === "brandsEnabled"}
|
busy={saving === "brandsEnabled"}
|
||||||
onChange={(v) => toggle("brandsEnabled", v)}
|
onChange={(v) => toggle("brandsEnabled", v)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<ToggleRow
|
|
||||||
label="Item types"
|
|
||||||
description="The item builder offers Color / Size / Material dimensions when creating items."
|
|
||||||
checked={config.itemTypesEnabled}
|
|
||||||
busy={saving === "itemTypesEnabled"}
|
|
||||||
onChange={(v) => toggle("itemTypesEnabled", v)}
|
|
||||||
note="Advisory: the app honours this, but the server cannot enforce it — items store no item-type reference. Turning it off hides the builder's section; it does not reject anything."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{config.updatedAt && (
|
{config.updatedAt && (
|
||||||
<p className="pt-2 text-sm text-muted-foreground">
|
<p className="pt-2 text-sm text-muted-foreground">
|
||||||
@@ -132,7 +128,6 @@ export default function ProductSettingsPage() {
|
|||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
subcategoriesEnabled: "Subcategories",
|
subcategoriesEnabled: "Subcategories",
|
||||||
brandsEnabled: "Brands",
|
brandsEnabled: "Brands",
|
||||||
itemTypesEnabled: "Item types",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToggleRow({
|
function ToggleRow({
|
||||||
@@ -141,28 +136,26 @@ function ToggleRow({
|
|||||||
checked,
|
checked,
|
||||||
busy,
|
busy,
|
||||||
onChange,
|
onChange,
|
||||||
note,
|
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onChange: (next: boolean) => void
|
onChange: (next: boolean) => void
|
||||||
note?: string
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-6 border-t py-4 first:border-t-0">
|
<div className="flex items-start gap-4 py-4">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={label}
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-base font-medium text-foreground">{label}</span>
|
<span className="text-base font-medium text-foreground">{label}</span>
|
||||||
<span className="text-sm text-muted-foreground">{description}</span>
|
<span className="text-sm text-muted-foreground">{description}</span>
|
||||||
{note && (
|
|
||||||
<span className="mt-1 inline-flex items-start gap-1.5 text-sm text-amber-700">
|
|
||||||
<Info className="mt-0.5 size-4 shrink-0" />
|
|
||||||
{note}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={checked} onCheckedChange={onChange} disabled={busy} aria-label={label} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
SwatchBook,
|
|
||||||
Tag,
|
Tag,
|
||||||
Truck,
|
Truck,
|
||||||
Users,
|
Users,
|
||||||
@@ -53,9 +52,7 @@ const navItems: {
|
|||||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||||
{ title: "Item Type", code: "products.item-type", href: "/dashboard/products/item-types", icon: SwatchBook },
|
|
||||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||||
@@ -73,6 +70,7 @@ const navItems: {
|
|||||||
children: [
|
children: [
|
||||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||||
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||||
|
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
|
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
|
||||||
|
|||||||
Reference in New Issue
Block a user