ui bug fixers

This commit is contained in:
2026-10-07 10:28:29 +05:30
committed by ImanThiyanga
parent 0d3f2fba14
commit 3abcfa6e6f
37 changed files with 1852 additions and 542 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -33,6 +33,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { EditCustomerDialog } from "@/components/customers/EditCustomerDialog"
type StatusFilter = EntityStatus | "All"
type TypeFilter = CustomerType | "All"
@@ -63,6 +64,10 @@ export default function CustomersPage() {
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
const [phone, setPhone] = useState("")
const [email, setEmail] = useState("")
const [addressLine1, setAddressLine1] = useState("")
const [addressLine2, setAddressLine2] = useState("")
const [city, setCity] = useState("")
const [country, setCountry] = useState("")
const [creditLimit, setCreditLimit] = useState("")
const [creditDays, setCreditDays] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
@@ -72,6 +77,8 @@ export default function CustomersPage() {
// customer's own code (mirrors the vendor create dialog).
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
// The customer whose edit popup is open.
const [editingId, setEditingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
@@ -135,6 +142,10 @@ export default function CustomersPage() {
setNewCustomerType("B2C")
setPhone("")
setEmail("")
setAddressLine1("")
setAddressLine2("")
setCity("")
setCountry("")
setCreditLimit("")
setCreditDays("")
setBranchFilterId(null)
@@ -156,6 +167,10 @@ export default function CustomersPage() {
name,
phone: phone || null,
email: email || null,
addressLine1: addressLine1 || null,
addressLine2: addressLine2 || null,
city: city || null,
country: country || null,
creditLimit: Number(creditLimit) || 0,
creditDays: Number(creditDays) || 0,
branchId: effectiveBranchId ?? 0,
@@ -187,6 +202,17 @@ export default function CustomersPage() {
}
}
/**
* Row click → profile page. React bubbles events out of portals along the component tree,
* so a click on the delete confirmation (portaled to <body>) still reaches the row; only
* navigate for events whose DOM target really is inside the row and not an action cell.
*/
function openRow(e: React.MouseEvent | React.KeyboardEvent, customerId: number) {
const target = e.target as HTMLElement
if (!e.currentTarget.contains(target) || target.closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${customerId}`)
}
const hasFilters = query.length > 0 || status !== "All" || customerType !== "All"
return (
@@ -213,7 +239,7 @@ export default function CustomersPage() {
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="c-name">Name</FieldLabel>
<FieldLabel htmlFor="c-name" required>Name</FieldLabel>
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
@@ -243,6 +269,24 @@ export default function CustomersPage() {
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
</div>
<Field>
<FieldLabel htmlFor="c-addr1">Address line 1 (optional)</FieldLabel>
<Input id="c-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" />
</Field>
<Field>
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
</Field>
<Field>
<FieldLabel htmlFor="c-country">Country (optional)</FieldLabel>
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
@@ -255,7 +299,7 @@ export default function CustomersPage() {
</div>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="c-branch">Branch</FieldLabel>
<FieldLabel htmlFor="c-branch" required>Branch</FieldLabel>
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger id="c-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
@@ -357,12 +401,9 @@ export default function CustomersPage() {
key={c.customerId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${c.customerId}`)
}}
onClick={(e) => openRow(e, c.customerId)}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`)
if (e.key === "Enter") openRow(e, c.customerId)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
@@ -398,14 +439,15 @@ export default function CustomersPage() {
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.customerCode}`}
title="Edit"
onClick={() => setEditingId(c.customerId)}
>
<Pencil className="size-4" />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger
@@ -460,10 +502,7 @@ export default function CustomersPage() {
<TableRow
key={c.customerId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${c.customerId}`)
}}
onClick={(e) => openRow(e, c.customerId)}
>
<TableCell>
<Link href={`/dashboard/customers/${c.customerId}`} className="font-semibold text-foreground hover:underline">
@@ -497,14 +536,15 @@ export default function CustomersPage() {
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.customerCode}`}
title="Edit"
onClick={() => setEditingId(c.customerId)}
>
<Pencil className="size-4" />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger
@@ -549,6 +589,21 @@ export default function CustomersPage() {
)}
</>
)}
{editingId !== null && (
<EditCustomerDialog
key={editingId}
customerId={editingId}
onClose={() => setEditingId(null)}
onSaved={(saved) => {
// The code is editable here — swap it so later creates de-dupe against the new one.
const oldCode = customers?.find((c) => c.customerId === saved.customerId)?.customerCode
setAllCustomerCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.customerCode])
setEditingId(null)
load()
}}
/>
)}
</div>
)
}
+16 -4
View File
@@ -24,6 +24,7 @@ import { DashboardStats } from "@/types/dashboard"
import { LedgerEntry } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { useIsMobile } from "@/hooks/use-mobile"
import { StatCard } from "@/components/ui/stat-card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
@@ -33,6 +34,8 @@ import BarChart from "@/components/ui/bar-chart"
import LineChart from "@/components/ui/line-chart"
const TREND_DAYS = 14
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
const CHART_MIN_SLOTS = 5
function isoDate(d: Date) {
return d.toISOString().slice(0, 10)
@@ -114,6 +117,13 @@ export default function DashboardPage() {
}
}, [trend])
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
const isMobile = useIsMobile()
const valuationRows = stats?.stockValuationByWarehouse ?? []
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
const loaded = stats && movements && trend && warehouses
return (
@@ -172,16 +182,18 @@ export default function DashboardPage() {
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
labels={[
...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`),
...Array<string>(valuationPad).fill(""),
]}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
data: [...valuationRows.map((w) => w.total), ...Array<null>(valuationPad).fill(null)],
backgroundColor: "#E11D48",
},
]}
barPercentage={0.6}
/>
</div>
) : (
@@ -294,7 +294,13 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
</div>
</section>
<PurchaseInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
<PurchaseInvoicePaymentDialog
invoice={invoice}
open={payDialogOpen}
onOpenChange={setPayDialogOpen}
onPaid={handlePaid}
onFullyPaid={() => router.push("/dashboard/receiving/grn")}
/>
</div>
)
}
@@ -433,7 +433,13 @@ export default function PurchaseInvoicesPage() {
)}
</div>
<PurchaseInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
<PurchaseInvoicePaymentDialog
invoice={payDialogInvoice}
open={payDialogOpen}
onOpenChange={setPayDialogOpen}
onPaid={handlePaid}
onFullyPaid={() => router.push("/dashboard/receiving/grn")}
/>
</div>
)
}
@@ -9,6 +9,8 @@ import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
import { grnsApi } from "@/lib/api/grns"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { validateReturnLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
@@ -23,11 +25,17 @@ import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Combobox } from "@/components/ui/combobox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import { HoldStatusBadge } from "@/components/receiving/status-badges"
// The stock checkbox (16px, faint border) and qty box are easy to miss on this form, so both are
// enlarged and given a solid border here.
const LINE_CHECKBOX = "size-5 shrink-0 border-2 border-muted-foreground/60"
const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background text-base font-medium md:text-base"
interface LineState {
selected: boolean
qty: string
@@ -42,6 +50,8 @@ function NewPurchaseReturnContent() {
const [grns, setGrns] = useState<Grn[] | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [vendorNames, setVendorNames] = useState<Map<number, string>>(new Map())
const [warehouseNames, setWarehouseNames] = useState<Map<number, string>>(new Map())
const [loadError, setLoadError] = useState<string | null>(null)
const [grnId, setGrnId] = useState<number | null>(presetGrnId)
@@ -62,8 +72,18 @@ function NewPurchaseReturnContent() {
setReasonCodes(rc.items)
})
.catch((err) => setLoadError(errorMessage(err)))
// Names only make the GRN picker searchable by vendor/warehouse — if these fail the
// labels fall back to "#id", so they don't block the form.
vendorsApi.list({ pageSize: 200 }).then((r) => setVendorNames(new Map(r.items.map((v) => [v.vendorId, v.name])))).catch(() => {})
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouseNames(new Map(r.items.map((w) => [w.warehouseId, w.name])))).catch(() => {})
}, [])
const grnOptions = (grns ?? []).map((g) => ({
value: g.grnId,
label: `${g.docNo} — ${vendorNames.get(g.vendorId) ?? `Vendor #${g.vendorId}`}, ${warehouseNames.get(g.warehouseId) ?? `Warehouse #${g.warehouseId}`}`,
}))
const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null
useEffect(() => {
@@ -87,14 +107,18 @@ function NewPurchaseReturnContent() {
}
function toggleLine(line: GrnLine) {
setLineState((prev) => ({
...prev,
[line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) },
}))
setLineState((prev) => {
const selected = !prev[line.grnLineId]?.selected
// Ticking pre-fills the full received qty; unticking clears it, so a qty is only ever
// shown on lines that will actually be returned.
return { ...prev, [line.grnLineId]: { selected, qty: selected ? prev[line.grnLineId]?.qty || String(line.qty) : "" } }
})
}
// Typing a quantity ticks the line (and clearing it / entering 0 unticks it), so the qty box
// can stay editable instead of sitting greyed-out until the checkbox is found and ticked.
function setQty(grnLineId: number, qty: string) {
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } }))
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } }))
}
async function handleSubmit() {
@@ -173,18 +197,15 @@ function NewPurchaseReturnContent() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Goods Receipt Note</Label>
<Select<number | null> value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a confirmed GRN" />
</SelectTrigger>
<SelectContent>
{(grns ?? []).map((g) => (
<SelectItem key={g.grnId} value={g.grnId} className="text-base">
{g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox<number>
items={grnOptions}
value={grnId}
onValueChange={setGrnId}
disabled={!!presetGrnId}
placeholder="Search GRN — number, vendor or warehouse…"
emptyText="No confirmed GRNs match."
className="h-12 text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Reason</Label>
@@ -218,9 +239,12 @@ function NewPurchaseReturnContent() {
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<label className="flex items-start gap-3">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
<div
key={line.grnLineId}
className={cn("flex flex-col gap-3 rounded-xl border p-4", state.selected && "border-primary bg-primary/5")}
>
<label className="flex cursor-pointer items-start gap-3">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className={cn(LINE_CHECKBOX, "mt-0.5")} />
<span className="min-w-0 truncate text-base font-medium text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</span>
@@ -238,17 +262,18 @@ function NewPurchaseReturnContent() {
</div>
<div>
<Label className="text-sm">Return qty</Label>
<Label className="text-sm font-medium text-foreground">Return qty</Label>
<Input
type="number"
min="0"
max={line.qty}
step="any"
inputMode="decimal"
value={state.qty}
disabled={!state.selected}
placeholder={`0 – ${line.qty}`}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
className={RETURN_QTY_INPUT}
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
@@ -275,9 +300,14 @@ function NewPurchaseReturnContent() {
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<TableRow key={line.grnLineId}>
<TableRow key={line.grnLineId} className={cn(state.selected && "bg-primary/5 hover:bg-primary/10")}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
<Checkbox
checked={state.selected}
onCheckedChange={() => toggleLine(line)}
className={LINE_CHECKBOX}
aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`}
/>
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
@@ -288,12 +318,14 @@ function NewPurchaseReturnContent() {
<Input
type="number"
min="0"
max={line.qty}
step="any"
value={state.qty}
disabled={!state.selected}
placeholder={`0 – ${line.qty}`}
aria-invalid={!!errors.qty}
aria-label="Return qty"
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
className={RETURN_QTY_INPUT}
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
@@ -86,10 +86,6 @@ export default function NewRequisitionPage() {
setSubmitError("Add at least one line.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setSubmitError("Select a branch for this requisition.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
@@ -142,11 +138,11 @@ export default function NewRequisitionPage() {
<>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label>
<Label className="text-base">Branch (optional)</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
<SelectValue placeholder="Default branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
@@ -37,7 +37,6 @@ import { toast } from "@/components/ui/toast"
import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
@@ -420,16 +419,6 @@ export default function ItemDetailPage() {
dialog closes so this copy picks up what changed there. */}
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
<ItemReorderPanel
key={item.itemId}
itemId={item.itemId}
stockNature={item.stockNature}
settings={item.reorder}
warehouses={warehouses}
uomName={uomName(item.baseUomId)}
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
@@ -288,12 +288,24 @@ export default function NewItemPage() {
*/
const productLabel = productName.trim()
/** Drops the given submit errors once the user edits the field they belong to. */
function clearErrors(...keys: string[]) {
setErrors((prev) => {
if (!keys.some((k) => prev[k])) return prev
const next = { ...prev }
for (const k of keys) delete next[k]
return next
})
}
function handleCategoryChange(value: number | null) {
setCategoryId(value)
setSubCategoryId(null)
clearErrors("categoryId")
}
function toggleItemType(itemTypeId: number) {
clearErrors("variants", "measurable", "variantContent")
setCheckedItemTypeIds((prev) =>
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
)
@@ -338,6 +350,7 @@ export default function NewItemPage() {
}
setItemOnlyWarrantyMonths(parsed.months)
setWarrantyPeriodMonths(parsed.months)
clearErrors("warrantyPeriodMonths")
closeCustomPeriod()
}
@@ -567,9 +580,18 @@ export default function NewItemPage() {
const nextPriceErrors =
config?.sellingPricesEnabled && priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
setPriceErrors(nextPriceErrors)
if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return
const messages = [...new Set([...Object.values(nextErrors), ...Object.values(nextPriceErrors)])]
if (messages.length > 0) {
toast.error(
"Please fix the highlighted fields",
messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`,
)
return
}
if (baseUomId === null) {
setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.")
const message = "No unit of measure exists yet — create one under Products → UOM before adding items."
setSubmitError(message)
toast.error("Could not create item", message)
return
}
@@ -689,10 +711,13 @@ export default function NewItemPage() {
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Product name</Label>
<Label className="text-base" required>Product name</Label>
<Input
value={productName}
onChange={(e) => setProductName(e.target.value)}
onChange={(e) => {
setProductName(e.target.value)
clearErrors("productName")
}}
placeholder="e.g. Coca Cola"
aria-invalid={!!errors.productName}
className="h-12! text-base"
@@ -705,7 +730,7 @@ export default function NewItemPage() {
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Label className="text-base" required>Category</Label>
<Select<number | null>
value={categoryId}
onValueChange={handleCategoryChange}
@@ -789,7 +814,7 @@ export default function NewItemPage() {
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<Label className="text-base" required>Base UOM</Label>
<Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
@@ -846,14 +871,20 @@ export default function NewItemPage() {
min="0"
step="any"
value={contentQty}
onChange={(e) => setContentQty(e.target.value)}
onChange={(e) => {
setContentQty(e.target.value)
clearErrors("contentQty", "contentUnit")
}}
placeholder="e.g. 500"
aria-invalid={!!errors.contentQty}
className="h-12! text-base"
/>
<Select<MeasureUnit | null>
value={contentUnit}
onValueChange={setContentUnit}
onValueChange={(v) => {
setContentUnit(v)
clearErrors("contentQty", "contentUnit")
}}
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
>
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
@@ -1004,7 +1035,10 @@ export default function NewItemPage() {
value={warranty}
onValueChange={(v) => {
setWarranty(v as typeof warranty)
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
if (v === "NonWarranty") {
setWarrantyPeriodMonths(null)
clearErrors("warrantyPeriodMonths")
}
}}
className="flex flex-col gap-3"
>
@@ -1020,10 +1054,14 @@ export default function NewItemPage() {
{warranty === "Warranty" && (
<div className="mt-2 flex flex-col gap-1.5">
<Label>Warranty period +</Label>
<Label required>Warranty period +</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
onValueChange={(v) => {
if (!v) return
setWarrantyPeriodMonths(v)
clearErrors("warrantyPeriodMonths")
}}
items={warrantySelectGroups.flatMap((g) => g.months).map((m) => ({
label: formatWarrantyPeriod(m),
value: m,
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
@@ -15,7 +15,7 @@ import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { PurchaseInvoice } from "@/types/purchase-invoices"
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -24,9 +24,12 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
export default function GrnDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const grnId = Number(params.id)
const [grn, setGrn] = useState<Grn | null>(null)
@@ -40,6 +43,28 @@ export default function GrnDetailPage() {
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
const [viewInvoiceOpen, setViewInvoiceOpen] = useState(false)
const [payOpen, setPayOpen] = useState(false)
// Bumped after each payment so the invoice preview reloads with the new paid/balance.
const [invoiceRefreshKey, setInvoiceRefreshKey] = useState(0)
/** Keeps the "Balance due" line here, and the invoice preview, in step with payments recorded. */
function handleInvoicePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
setInvoiceRefreshKey((k) => k + 1)
setLinkedInvoice((prev) =>
prev
? {
...prev,
status: newStatus,
totals: {
...prev.totals,
paidAmount: prev.totals.paidAmount + payment.amount,
balanceAmount: prev.totals.balanceAmount - payment.amount,
},
}
: prev
)
}
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
const idempotencyKey = useRef(crypto.randomUUID())
@@ -429,13 +454,10 @@ export default function GrnDetailPage() {
</span>
)}
</div>
<Link
href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<Button variant="outline" size="sm" onClick={() => setViewInvoiceOpen(true)}>
<FileText className="size-4" />
View invoice
</Link>
</Button>
</div>
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -451,6 +473,28 @@ export default function GrnDetailPage() {
)}
</div>
)}
<PurchaseInvoicePrintDialog
purchaseInvoiceId={grn.purchaseInvoiceId ?? null}
invoiceNo={linkedInvoice?.docNo}
open={viewInvoiceOpen}
onOpenChange={setViewInvoiceOpen}
refreshKey={invoiceRefreshKey}
actions={
linkedInvoice && linkedInvoice.status !== "Cancelled" ? (
<Button size="sm" onClick={() => setPayOpen(true)} disabled={linkedInvoice.totals.balanceAmount <= 0}>
{linkedInvoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
</Button>
) : undefined
}
/>
<PurchaseInvoicePaymentDialog
invoice={linkedInvoice}
open={payOpen}
onOpenChange={setPayOpen}
onPaid={handleInvoicePaid}
onFullyPaid={() => router.push("/dashboard/receiving/grn")}
/>
</div>
)
}
+15 -3
View File
@@ -77,9 +77,21 @@ export default function VendorDetailPage() {
.catch(() => {})
}, [vendorId])
/** Drops fields' errors once the user starts editing them. */
function clearErrors(...keys: string[]) {
setErrors((prev) => {
if (!keys.some((k) => prev[k])) return prev
const next = { ...prev }
for (const k of keys) delete next[k]
return next
})
}
function handleNameChange(value: string) {
setName(value)
setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
// The code is regenerated from the name, so its error is stale too.
clearErrors("name", "code")
}
function applyVendor(data: Vendor) {
@@ -457,7 +469,7 @@ export default function VendorDetailPage() {
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Label className="text-base" required>Name</Label>
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
@@ -470,8 +482,8 @@ export default function VendorDetailPage() {
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Currency</Label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
<Label className="text-base" required>Currency</Label>
<Input value={currency} onChange={(e) => { setCurrency(e.target.value); clearErrors("currency") }} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</div>
</div>
+71 -34
View File
@@ -33,6 +33,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
type StatusFilter = EntityStatus | "All"
@@ -69,6 +70,8 @@ export default function VendorsPage() {
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
// The vendor whose edit popup is open.
const [editingId, setEditingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
@@ -130,6 +133,16 @@ export default function VendorsPage() {
setErrors({})
}
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
@@ -155,14 +168,15 @@ export default function VendorsPage() {
}
}
// FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI
// maps to PATCH status → Inactive, same mechanism as the detail page's toggle.
async function handleDeactivate(vendor: Vendor) {
// Hard delete. The server refuses (409) a vendor any purchase document or item still
// references — those can only be deactivated, from the detail page's status toggle.
async function handleDelete(vendor: Vendor) {
setActionPendingId(vendor.vendorId)
try {
await vendorsApi.updateStatus(vendor.vendorId, "Inactive")
toast.success("Vendor deleted", `${vendor.code} has been deactivated.`)
await vendorsApi.delete(vendor.vendorId)
toast.success("Vendor deleted", `${vendor.code} — ${vendor.name}`)
load()
setAllVendorCodes((codes) => codes.filter((c) => c !== vendor.code))
} catch (err) {
toast.error("Could not delete vendor", errorMessage(err))
} finally {
@@ -170,6 +184,17 @@ export default function VendorsPage() {
}
}
/**
* Row click → detail page. React bubbles events out of portals along the component tree,
* so a click on the delete confirmation (portaled to <body>) still reaches the row; only
* navigate for events whose DOM target really is inside the row and not an action cell.
*/
function openRow(e: React.MouseEvent | React.KeyboardEvent, vendorId: number) {
const target = e.target as HTMLElement
if (!e.currentTarget.contains(target) || target.closest("[data-no-row-nav]")) return
router.push(`/dashboard/vendors/${vendorId}`)
}
const hasFilters = query.length > 0 || status !== "All"
return (
@@ -196,8 +221,8 @@ export default function VendorsPage() {
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="v-name">Name</FieldLabel>
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldLabel htmlFor="v-name" required>Name</FieldLabel>
<Input id="v-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
@@ -213,14 +238,14 @@ export default function VendorsPage() {
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="v-currency">Currency</FieldLabel>
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
<FieldLabel htmlFor="v-currency" required>Currency</FieldLabel>
<Input id="v-currency" value={currency} onChange={(e) => { setCurrency(e.target.value); clearError("currency") }} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</Field>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="v-branch">Branch</FieldLabel>
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<FieldLabel htmlFor="v-branch" required>Branch</FieldLabel>
<Select<number | null> value={branchFilterId} onValueChange={(v) => { setBranchFilterId(v); clearError("branch") }}>
<SelectTrigger id="v-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
@@ -311,12 +336,9 @@ export default function VendorsPage() {
key={v.vendorId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/vendors/${v.vendorId}`)
}}
onClick={(e) => openRow(e, v.vendorId)}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/vendors/${v.vendorId}`)
if (e.key === "Enter") openRow(e, v.vendorId)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
@@ -356,14 +378,15 @@ export default function VendorsPage() {
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Update ${v.code}`}
title="Edit"
onClick={() => setEditingId(v.vendorId)}
>
<Pencil className="size-4" />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger
@@ -373,7 +396,7 @@ export default function VendorsPage() {
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${v.code}`}
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
disabled={actionPendingId === v.vendorId}
/>
}
>
@@ -382,9 +405,9 @@ export default function VendorsPage() {
<AlertDialogContent
variant="destructive"
title={`Delete ${v.code}?`}
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
description={`This permanently deletes ${v.name} and cannot be undone. A vendor used on purchase documents or items can't be deleted — deactivate it from its detail page instead.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(v)}
onConfirm={() => handleDelete(v)}
/>
</AlertDialog>
</div>
@@ -418,10 +441,7 @@ export default function VendorsPage() {
<TableRow
key={v.vendorId}
className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/vendors/${v.vendorId}`)
}}
onClick={(e) => openRow(e, v.vendorId)}
>
<TableCell>
<Link href={`/dashboard/vendors/${v.vendorId}`} className="font-semibold text-foreground hover:underline">
@@ -457,14 +477,15 @@ export default function VendorsPage() {
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Update ${v.code}`}
title="Edit"
onClick={() => setEditingId(v.vendorId)}
>
<Pencil className="size-4" />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger
@@ -474,7 +495,7 @@ export default function VendorsPage() {
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${v.code}`}
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
disabled={actionPendingId === v.vendorId}
/>
}
>
@@ -483,9 +504,9 @@ export default function VendorsPage() {
<AlertDialogContent
variant="destructive"
title={`Delete ${v.code}?`}
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
description={`This permanently deletes ${v.name} and cannot be undone. A vendor used on purchase documents or items can't be deleted — deactivate it from its detail page instead.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(v)}
onConfirm={() => handleDelete(v)}
/>
</AlertDialog>
</div>
@@ -509,6 +530,22 @@ export default function VendorsPage() {
)}
</>
)}
{editingId !== null && (
<EditVendorDialog
key={editingId}
vendorId={editingId}
existingCodes={allVendorCodes}
onClose={() => setEditingId(null)}
onSaved={(saved) => {
// A renamed vendor gets a regenerated code — swap it so later creates de-dupe correctly.
const oldCode = vendors?.find((v) => v.vendorId === saved.vendorId)?.code
setAllVendorCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.code])
setEditingId(null)
load()
}}
/>
)}
</div>
)
}
@@ -10,6 +10,7 @@ import { itemsApi } from "@/lib/api/items"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { LedgerEntry, OnHand } from "@/types/stock"
import { useIsMobile } from "@/hooks/use-mobile"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
@@ -31,6 +32,8 @@ import BarChart from "@/components/ui/bar-chart"
/** Charts stay legible with a lot of items only by capping to the top N by on-hand qty. */
const CHART_ROW_LIMIT = 15
/** Fewest bar slots the charts' axis spans, so a couple of bars sit at the start (see padSlots). */
const CHART_MIN_SLOTS = 5
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
@@ -120,8 +123,18 @@ export default function WarehouseDetailPage() {
const stockRows = useMemo(() => (stock ?? []).slice().sort((a, b) => b.onHand - a.onHand), [stock])
const totalOnHand = stockRows.reduce((sum, r) => sum + r.onHand, 0)
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS bars,
// Chart.js would center them across the full width. Trailing blank slots keep the bars at the
// start. Skipped on mobile, where the bars should stretch to fill the narrower card instead.
const isMobile = useIsMobile()
const padSlots = (count: number) => (isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - count))
const chartRows = stockRows.slice(0, CHART_ROW_LIMIT)
const chartLabels = chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`)
const chartPad = padSlots(chartRows.length)
const chartLabels = [
...chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`),
...Array<string>(chartPad).fill(""),
]
const movementByItem = useMemo(() => {
const map = new Map<number, { in: number; out: number }>()
@@ -137,12 +150,24 @@ export default function WarehouseDetailPage() {
}, [ledger])
const movementChartRows = movementByItem.slice(0, CHART_ROW_LIMIT)
const movementChartLabels = movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`)
const movementChartPad = padSlots(movementChartRows.length)
const movementChartLabels = [
...movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`),
...Array<string>(movementChartPad).fill(""),
]
const pagedBins = bins?.slice((binsPage - 1) * binsPageSize, binsPage * binsPageSize) ?? []
const pagedStockRows = stockRows.slice((stockPage - 1) * stockPageSize, stockPage * stockPageSize)
const pagedMovementRows = movementByItem.slice((movementPage - 1) * movementPageSize, movementPage * movementPageSize)
/** Closes the new-bin dialog and clears whatever was typed, so the next open starts blank. */
function closeCreate() {
setOpen(false)
setCode("")
setBinType("")
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Bin code is required"
@@ -153,10 +178,7 @@ export default function WarehouseDetailPage() {
try {
const bin = await warehousesApi.createBin(warehouseId, { code, binType: binType || null })
toast.success("Bin created", bin.code)
setOpen(false)
setCode("")
setBinType("")
setErrors({})
closeCreate()
loadBins()
} catch (err) {
const fe = fieldErrors(err)
@@ -198,7 +220,7 @@ export default function WarehouseDetailPage() {
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : closeCreate())}>
<DialogTrigger
render={
<Button size="lg">
@@ -214,8 +236,8 @@ export default function WarehouseDetailPage() {
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="bin-code">Code</FieldLabel>
<Input id="bin-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="A-01-01" aria-invalid={!!errors.code} />
<FieldLabel htmlFor="bin-code" required>Code</FieldLabel>
<Input id="bin-code" value={code} onChange={(e) => { setCode(e.target.value); setErrors({}) }} placeholder="A-01-01" aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field>
@@ -224,7 +246,7 @@ export default function WarehouseDetailPage() {
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={closeCreate} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
@@ -332,9 +354,18 @@ export default function WarehouseDetailPage() {
<BarChart
labels={chartLabels}
datasets={[
{ label: "On hand", data: chartRows.map((r) => r.onHand), backgroundColor: "rgba(239,68,68,0.6)" },
{ label: "Available", data: chartRows.map((r) => r.available), backgroundColor: "rgba(34,197,94,0.6)" },
{
label: "On hand",
data: [...chartRows.map((r) => r.onHand), ...Array<null>(chartPad).fill(null)],
backgroundColor: "rgba(239,68,68,0.6)",
},
{
label: "Available",
data: [...chartRows.map((r) => r.available), ...Array<null>(chartPad).fill(null)],
backgroundColor: "rgba(34,197,94,0.6)",
},
]}
barPercentage={0.6}
/>
</div>
@@ -451,9 +482,18 @@ export default function WarehouseDetailPage() {
<BarChart
labels={movementChartLabels}
datasets={[
{ label: "In", data: movementChartRows.map((r) => r.in), backgroundColor: "rgba(34,197,94,0.6)" },
{ label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "rgba(239,68,68,0.6)" },
{
label: "In",
data: [...movementChartRows.map((r) => r.in), ...Array<null>(movementChartPad).fill(null)],
backgroundColor: "rgba(34,197,94,0.6)",
},
{
label: "Out",
data: [...movementChartRows.map((r) => r.out), ...Array<null>(movementChartPad).fill(null)],
backgroundColor: "rgba(239,68,68,0.6)",
},
]}
barPercentage={0.6}
/>
</div>
@@ -126,10 +126,30 @@ export default function WarehousesPage() {
return () => clearTimeout(t)
}, [search])
/** State updater that drops one field's error once the user starts editing that field. */
function dropError(key: string) {
return (prev: Record<string, string>) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
}
}
function binCount(warehouseId: number) {
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
}
/** Closes the create dialog and clears whatever was typed, so the next open starts blank. */
function closeCreate() {
setOpen(false)
setName("")
setAddress("")
setPhone("")
setBranchId(0)
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Warehouse name is required"
@@ -141,12 +161,7 @@ export default function WarehousesPage() {
try {
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId })
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
setOpen(false)
setName("")
setAddress("")
setPhone("")
setBranchId(0)
setErrors({})
closeCreate()
load()
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
@@ -219,7 +234,7 @@ export default function WarehousesPage() {
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : closeCreate())}>
<DialogTrigger
render={
<Button size="lg">
@@ -235,8 +250,8 @@ export default function WarehousesPage() {
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="wh-name">Name</FieldLabel>
<Input id="wh-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
<FieldLabel htmlFor="wh-name" required>Name</FieldLabel>
<Input id="wh-name" value={name} onChange={(e) => { setName(e.target.value); setErrors(dropError("name")) }} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
@@ -253,8 +268,8 @@ export default function WarehousesPage() {
</Field>
{warehouseBranchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="wh-branch">Branch</FieldLabel>
<Select value={String(branchId)} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
<FieldLabel htmlFor="wh-branch" required>Branch</FieldLabel>
<Select value={String(branchId)} onValueChange={(v) => { setBranchId(v ? Number(v) : 0); setErrors(dropError("branchId")) }}>
<SelectTrigger id="wh-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
@@ -263,7 +278,7 @@ export default function WarehousesPage() {
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={closeCreate} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
@@ -528,13 +543,13 @@ export default function WarehousesPage() {
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="wh-edit-name">Name</FieldLabel>
<Input id="wh-edit-name" value={editName} onChange={(e) => setEditName(e.target.value)} aria-invalid={!!editErrors.name} />
<FieldLabel htmlFor="wh-edit-name" required>Name</FieldLabel>
<Input id="wh-edit-name" value={editName} onChange={(e) => { setEditName(e.target.value); setEditErrors(dropError("name")) }} aria-invalid={!!editErrors.name} />
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
</Field>
<Field data-invalid={!!editErrors.code}>
<FieldLabel htmlFor="wh-edit-code">Code</FieldLabel>
<Input id="wh-edit-code" value={editCode} onChange={(e) => setEditCode(e.target.value)} aria-invalid={!!editErrors.code} />
<FieldLabel htmlFor="wh-edit-code" required>Code</FieldLabel>
<Input id="wh-edit-code" value={editCode} onChange={(e) => { setEditCode(e.target.value); setEditErrors(dropError("code")) }} aria-invalid={!!editErrors.code} />
<FieldError errors={[editErrors.code ? { message: editErrors.code } : undefined]} />
</Field>
<Field>
@@ -547,8 +562,8 @@ export default function WarehousesPage() {
</Field>
{warehouseBranchingEnabled && (
<Field data-invalid={!!editErrors.branchId}>
<FieldLabel htmlFor="wh-edit-branch">Branch</FieldLabel>
<Select value={String(editBranchId)} onValueChange={(v) => setEditBranchId(v ? Number(v) : 0)}>
<FieldLabel htmlFor="wh-edit-branch" required>Branch</FieldLabel>
<Select value={String(editBranchId)} onValueChange={(v) => { setEditBranchId(v ? Number(v) : 0); setEditErrors(dropError("branchId")) }}>
<SelectTrigger id="wh-edit-branch" aria-invalid={!!editErrors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+2 -2
View File
@@ -16,8 +16,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Hexa ERP",
description: "Hexa ERP — inventory, procurement, sales and accounting.",
};
export default function RootLayout({