ui bug fixers
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user