feat: add ItemOverviewDto for enriched item data in items list

feat: implement CustomerDetailPage for detailed customer view and editing

feat: create CustomersPage for listing and managing customers with filters

feat: add ColumnsMenu component for customizable table columns visibility

feat: implement CSV export functionality for data download

feat: add customer code generation logic for unique customer identifiers
This commit is contained in:
Dhananjaya99
2026-09-22 00:32:40 +05:30
parent 72819e5f7b
commit fdd104284e
44 changed files with 2186 additions and 381 deletions
@@ -0,0 +1,438 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Pencil, Receipt, Save, Users } from "lucide-react"
import { customersApi } from "@/lib/api/customers"
import { salesApi } from "@/lib/api/sales"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Customer, CustomerType } from "@/types/customers"
import { SalesInvoiceSummary } from "@/types/sales"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
/** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function CustomerDetailPage() {
const params = useParams<{ id: string }>()
const customerId = Number(params.id)
const [customer, setCustomer] = useState<Customer | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [customerCode, setCustomerCode] = useState("")
const [customerType, setCustomerType] = useState<CustomerType>("B2C")
const [name, setName] = useState("")
const [displayName, setDisplayName] = useState("")
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 [taxRegistrationNo, setTaxRegistrationNo] = useState("")
const [creditLimit, setCreditLimit] = useState("0")
const [creditDays, setCreditDays] = useState("0")
const [errors, setErrors] = useState<Record<string, string>>({})
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
// Profile activity: this customer's recent sales invoices ("selling").
const [invoices, setInvoices] = useState<SalesInvoiceSummary[] | null>(null)
function applyCustomer(data: Customer) {
setCustomer(data)
setCustomerCode(data.customerCode)
setCustomerType(data.customerType)
setName(data.name)
setDisplayName(data.displayName ?? "")
setPhone(data.phone ?? "")
setEmail(data.email ?? "")
setAddressLine1(data.addressLine1 ?? "")
setAddressLine2(data.addressLine2 ?? "")
setCity(data.city ?? "")
setCountry(data.country ?? "")
setTaxRegistrationNo(data.taxRegistrationNo ?? "")
setCreditLimit(String(data.creditLimit))
setCreditDays(String(data.creditDays))
}
function load() {
setLoadError(null)
customersApi
.get(customerId)
.then(({ data, etag: tag }) => {
applyCustomer(data)
setEtag(tag)
setConflict(false)
})
.catch((err) => setLoadError(errorMessage(err)))
}
useEffect(() => {
if (Number.isFinite(customerId)) load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [customerId])
/** Opens the edit dialog fresh from the last-loaded customer, discarding any unsaved
* edits left over from a previous cancelled session. */
function openEdit() {
if (customer) applyCustomer(customer)
setErrors({})
setSaveError(null)
setEditOpen(true)
}
function closeEdit() {
if (customer) applyCustomer(customer)
setEditOpen(false)
}
useEffect(() => {
if (!Number.isFinite(customerId)) return
salesApi.listInvoices({ customerId, pageSize: 100 }).then((res) => setInvoices(res.items)).catch(() => setInvoices([]))
}, [customerId])
const totalSold = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.grandTotal, 0), [invoices])
const outstandingBalance = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.balanceAmount, 0), [invoices])
const recentInvoices = useMemo(
() => (invoices ?? []).slice().sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()).slice(0, 8),
[invoices]
)
const monthlySales = useMemo(() => {
const byMonth = new Map<string, number>()
for (const inv of invoices ?? []) {
const key = inv.invoiceDate.slice(0, 7)
byMonth.set(key, (byMonth.get(key) ?? 0) + inv.totals.grandTotal)
}
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6)
}, [invoices])
async function handleSave() {
if (!customer || !etag) return
setSaveError(null)
const nextErrors: Record<string, string> = {}
if (!customerCode.trim()) nextErrors.customerCode = "Customer code is required"
if (!name.trim()) nextErrors.name = "Customer name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
try {
const result = await customersApi.update(
customerId,
{
customerCode,
customerType,
name,
displayName: displayName || null,
phone: phone || null,
email: email || null,
addressLine1: addressLine1 || null,
addressLine2: addressLine2 || null,
city: city || null,
country: country || null,
taxRegistrationNo: taxRegistrationNo || null,
creditLimit: Number(creditLimit) || 0,
creditDays: Number(creditDays) || 0,
defaultWarehouseId: customer.defaultWarehouseId,
branchId: customer.branchId,
},
etag
)
applyCustomer(result.data)
setEtag(result.etag)
setEditOpen(false)
toast.success("Customer saved", `${result.data.customerCode} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
if (code === "CONCURRENCY_CONFLICT") {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
setEditOpen(false)
return
}
const fe = fieldErrors(err)
if (fe?.customerCode) setErrors({ customerCode: fe.customerCode })
setSaveError(errorMessage(err))
toast.error("Could not save customer", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleToggleStatus() {
if (!customer) return
const next = customer.status === "Active" ? "Inactive" : "Active"
setTogglingStatus(true)
try {
await customersApi.updateStatus(customerId, next)
toast.success(next === "Active" ? "Customer activated" : "Customer deactivated")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingStatus(false)
}
}
if (loadError && !customer) {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
<Link href="/dashboard/customers" className={cn(buttonVariants({ variant: "outline" }))}>
Back to customers
</Link>
</div>
)
}
if (!customer) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<div className="flex flex-col gap-6">
<Link
href="/dashboard/customers"
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to Customers
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Users className="size-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{customer.customerCode}</h1>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
customer.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{customer.status}
</Badge>
</div>
<p className="text-base text-muted-foreground">{customer.displayName ?? customer.name}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="lg" variant="outline" onClick={openEdit}>
<Pencil className="size-4" />
Edit
</Button>
<Button
size="lg"
variant={customer.status === "Active" ? "destructive" : "success"}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : customer.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total sold" value={invoices === null ? "…" : `LKR ${totalSold.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={Receipt} label="Invoices" value={invoices === null ? "…" : String(invoices.length)} />
<StatChip
icon={AlertTriangle}
label="Outstanding balance"
value={invoices === null ? "…" : `LKR ${outstandingBalance.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
/>
</div>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This customer was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>
Reload
</Button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Monthly sales</h2>
{invoices === null ? (
<Skeleton className="h-56 w-full" />
) : monthlySales.length > 0 ? (
<BarChart
labels={monthlySales.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" }))}
datasets={[{ label: "Sold (LKR)", data: monthlySales.map(([, v]) => v), backgroundColor: "rgba(168,85,247,0.6)" }]}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No sales recorded yet.</p>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent sales invoices</h2>
{invoices === null ? (
<Skeleton className="h-56 w-full" />
) : recentInvoices.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sales to this customer yet.</p>
) : (
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Invoice</TableHead>
<TableHead className="h-9 px-2 text-xs">Status</TableHead>
<TableHead className="h-9 px-2 text-xs">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentInvoices.map((inv) => (
<TableRow key={inv.salesInvoiceId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
<Link href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`} className="font-medium text-primary hover:underline">
{inv.invoiceNo}
</Link>
</TableCell>
<TableCell className="px-2 py-2">{inv.status}</TableCell>
<TableCell className="px-2 py-2">LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {customer.customerCode}</DialogTitle>
<DialogDescription>Update this customer&apos;s details.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Code</Label>
<Input value={customerCode} onChange={(e) => setCustomerCode(e.target.value)} aria-invalid={!!errors.customerCode} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.customerCode ? { message: errors.customerCode } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Type</Label>
<Select<CustomerType> value={customerType} onValueChange={(v) => v && setCustomerType(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2C" className="text-base">B2C — individual</SelectItem>
<SelectItem value="B2B" className="text-base">B2B — business</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Display name (optional)</Label>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Phone</Label>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Email</Label>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 1</Label>
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Address line 2</Label>
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">City</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Country</Label>
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit limit (LKR)</Label>
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Credit days</Label>
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
</div>
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,408 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, Trash2, Users } from "lucide-react"
import { customersApi } from "@/lib/api/customers"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { generateCustomerCode } from "@/lib/customer-code"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Customer, CustomerType } from "@/types/customers"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All"
type TypeFilter = CustomerType | "All"
const PAGE_SIZE = 25
export default function CustomersPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [customers, setCustomers] = useState<Customer[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [customerType, setCustomerType] = useState<TypeFilter>("All")
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
const [phone, setPhone] = useState("")
const [email, setEmail] = useState("")
const [creditLimit, setCreditLimit] = useState("0")
const [creditDays, setCreditDays] = useState("0")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Every other customer's code, to de-dupe against when the name edit regenerates this
// customer's own code (mirrors the vendor create dialog).
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, customerType])
function load() {
setError(null)
customersApi
.list({
q: query || undefined,
status: status === "All" ? undefined : status,
customerType: customerType === "All" ? undefined : customerType,
page,
pageSize: PAGE_SIZE,
})
.then((res) => {
setCustomers(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [query, status, customerType, page])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
}, [])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function resetForm() {
setName("")
setNewCustomerType("B2C")
setPhone("")
setEmail("")
setCreditLimit("0")
setCreditDays("0")
setBranchFilterId(null)
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Customer name is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await customersApi.create({
customerCode: generatedCode,
customerType: newCustomerType,
name,
phone: phone || null,
email: email || null,
creditLimit: Number(creditLimit) || 0,
creditDays: Number(creditDays) || 0,
branchId: effectiveBranchId ?? 0,
})
toast.success("Customer created", `${result.data.customerCode} — ${result.data.name}`)
setOpen(false)
resetForm()
load()
setAllCustomerCodes((codes) => [...codes, result.data.customerCode])
} catch (err) {
toast.error("Could not create customer", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI maps to
* PATCH status → Inactive, same mechanism as the profile page's toggle. */
async function handleDeactivate(customer: Customer) {
setActionPendingId(customer.customerId)
try {
await customersApi.updateStatus(customer.customerId, "Inactive")
toast.success("Customer deleted", `${customer.customerCode} has been deactivated.`)
load()
} catch (err) {
toast.error("Could not delete customer", errorMessage(err))
} finally {
setActionPendingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All" || customerType !== "All"
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Customers</h1>
<p className="text-base text-muted-foreground">Customer master data used by sales invoices, slips and bundles.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg" onClick={resetForm}>
<Plus className="size-5" />
New Customer
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New customer</DialogTitle>
<DialogDescription>Its code is generated from the name — everything else can be filled in later from its profile.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="c-name">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>
<Field>
<FieldLabel htmlFor="c-code">Code (auto-generated)</FieldLabel>
<Input id="c-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
<Field>
<FieldLabel htmlFor="c-type">Customer type</FieldLabel>
<Select<CustomerType> value={newCustomerType} onValueChange={(v) => v && setNewCustomerType(v)}>
<SelectTrigger id="c-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2C">B2C — individual</SelectItem>
<SelectItem value="B2B">B2B — business</SelectItem>
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
<Input id="c-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
<Field>
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="c-credit-limit" type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
<Input id="c-credit-days" type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field>
</div>
{showBranchPicker && (
<Field data-invalid={!!errors.branch}>
<FieldLabel htmlFor="c-branch">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" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search code or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search customers"
/>
</div>
<Select<TypeFilter> value={customerType} onValueChange={(v) => setCustomerType(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All types</SelectItem>
<SelectItem value="B2C" className="text-base">B2C</SelectItem>
<SelectItem value="B2B" className="text-base">B2B</SelectItem>
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && customers === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && customers !== null && customers.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Users className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No customers match your search/filter." : "No customers yet."}
</p>
</div>
)}
{!error && customers !== null && customers.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Phone</TableHead>
<TableHead className="h-12 px-3 text-sm">Credit limit</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.map((c) => (
<TableRow
key={c.customerId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${c.customerId}`)
}}
>
<TableCell className="px-3 py-3.5 font-medium">{c.customerCode}</TableCell>
<TableCell className="px-3 py-3.5">{c.displayName ?? c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline">{c.customerType}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{c.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${c.customerCode}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.customerCode}`}
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.customerCode}?`}
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
)
}
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Download, Eye, Plus, Upload } from "lucide-react"
import { useRouter } from "next/navigation"
import { Download, Plus, Upload } from "lucide-react"
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map"
@@ -30,6 +30,7 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
}
export default function AttendanceBatchesPage() {
const router = useRouter()
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -119,20 +120,20 @@ export default function AttendanceBatchesPage() {
<TableHead className="h-12 px-3 text-sm">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batches.map((b) => (
<TableRow key={b.attendanceUploadBatchId}>
<TableRow
key={b.attendanceUploadBatchId}
className="cursor-pointer hover:bg-muted/40"
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
>
<TableCell className="px-3 py-3.5 font-medium">{b.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountTotal}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</TableCell>
<TableCell className="px-3 py-3.5"><Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge></TableCell>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label="View"><Eye className="size-4" /></Link>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
@@ -30,6 +31,7 @@ const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned",
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
export default function EmployeesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
@@ -279,8 +281,15 @@ export default function EmployeesPage() {
</TableHeader>
<TableBody>
{employees.map((e) => (
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<TableRow
key={e.employeeId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(ev) => {
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
@@ -325,11 +334,8 @@ export default function EmployeesPage() {
</Badge>
</TableCell>
<TableCell className="px-3 py-4">
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
@@ -1,9 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
import { useParams, useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, FileText, Search, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -27,6 +26,7 @@ function money(n: number): string {
export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const runId = Number(params.id)
const [run, setRun] = useState<PayrollRun | null>(null)
@@ -196,7 +196,14 @@ export default function PayrollRunDetailPage() {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<TableRow key={l.payrollLineId} className="hover:bg-muted/20">
<TableRow
key={l.payrollLineId}
className="cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium text-foreground">
{l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
</TableCell>
@@ -205,16 +212,8 @@ export default function PayrollRunDetailPage() {
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(l.totalAllowances)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</TableCell>
<TableCell className="px-4 py-3.5 font-semibold text-foreground">{money(l.netSalary)}</TableCell>
<TableCell className="px-4 py-3.5">
<TableCell className="px-4 py-3.5" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View breakdown for ${l.employeeName}`}
title="View breakdown"
>
<Eye className="size-4" />
</Link>
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
@@ -1,8 +1,8 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -31,6 +31,7 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
}
export default function PayrollRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<PayrollRun[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -152,12 +153,15 @@ export default function PayrollRunsPage() {
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((r) => (
<TableRow key={r.payrollRunId} className="hover:bg-muted/20">
<TableRow
key={r.payrollRunId}
className="cursor-pointer hover:bg-muted/20"
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
>
<TableCell className="px-4 py-4 font-mono text-sm text-foreground">{r.docNo}</TableCell>
<TableCell className="px-4 py-4 text-sm text-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</TableCell>
@@ -168,18 +172,6 @@ export default function PayrollRunsPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center">
<Link
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${r.docNo}`}
title={`View ${r.docNo}`}
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { useRouter } from "next/navigation"
import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -33,6 +34,7 @@ const tabs: { value: StatusFilter; label: string }[] = [
]
export default function PurchaseInvoicesPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
@@ -209,7 +211,14 @@ export default function PurchaseInvoicesPage() {
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40">
<TableRow
key={row.purchaseInvoiceId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo}
@@ -235,7 +244,7 @@ export default function PurchaseInvoicesPage() {
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<div className="flex items-center justify-end gap-1">
{row.status === "Draft" && (
<Button
@@ -262,14 +271,6 @@ export default function PurchaseInvoicesPage() {
Pay
</Button>
)}
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.docNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
@@ -174,7 +174,7 @@ export default function PurchaseOrderDetailPage() {
setSaving(true)
try {
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag)
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag)
setPo(result.data)
setEtag(result.etag)
setLines(toDraftLines(result.data))
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react"
import { useRouter } from "next/navigation"
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -34,6 +35,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
*/
export default function PurchaseOrderApprovalsPage() {
const router = useRouter()
const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -156,7 +158,14 @@ export default function PurchaseOrderApprovalsPage() {
{pos.map((po) => {
const vendor = vendorsById.get(po.vendorId)
return (
<TableRow key={po.poId} className="align-top">
<TableRow
key={po.poId}
className="align-top cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
@@ -173,7 +182,7 @@ export default function PurchaseOrderApprovalsPage() {
{new Date(po.createdAt).toLocaleString()}
</div>
</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Button
type="button"
@@ -195,14 +204,6 @@ export default function PurchaseOrderApprovalsPage() {
<X className="size-4" />
Reject
</Button>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label="View"
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
@@ -10,6 +10,7 @@ import { requisitionsApi } from "@/lib/api/requisitions"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
@@ -19,7 +20,9 @@ import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
@@ -66,11 +69,14 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
const [loadError, setLoadError] = useState<string | null>(null)
@@ -211,6 +217,21 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [requisitionId, rfqId, rfqVendorId])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
// An Admin on a specific header branch is pinned to it here too (shown, not editable) —
// the dropdown only appears when the header itself is on "All Branches", so there's no
// branch to pin to and the Admin must pick one explicitly for this PO.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -231,6 +252,10 @@ function NewPurchaseOrderContent() {
setHeaderError("Select a vendor.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this purchase order.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
return
@@ -268,6 +293,7 @@ function NewPurchaseOrderContent() {
requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines,
saveAsDraft,
branchId: effectiveBranchId ?? 0,
})
toast.success(
"Purchase order created",
@@ -369,6 +395,30 @@ function NewPurchaseOrderContent() {
</Dialog>
</div>
</div>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
{requisitionId && (
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
)}
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -32,6 +33,7 @@ type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 25
export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -203,7 +205,14 @@ export default function PurchaseOrdersListPage() {
{visiblePos.map((po) => {
const editable = isPoEditable(po.status)
return (
<TableRow key={po.poId}>
<TableRow
key={po.poId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
@@ -220,16 +229,8 @@ export default function PurchaseOrdersListPage() {
</TableCell>
)}
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="View"
title="View"
>
<Eye className="size-4" />
</Link>
{editable && (
<>
<Link
@@ -7,15 +7,19 @@ import { Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { validateRequisitionLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { CreateReqLineInput } from "@/types/procurement"
import { ItemListItem } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
@@ -40,6 +44,7 @@ function emptyLine(): DraftLine {
export default function NewRequisitionPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
@@ -47,11 +52,25 @@ export default function NewRequisitionPage() {
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
useEffect(() => {
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
}, [])
// Admin-only branch picker — non-admins never see this control, their own employee
// branch is used directly (mirrors the New GRN / New Sale pattern).
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -67,6 +86,10 @@ 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) {
@@ -87,7 +110,7 @@ export default function NewRequisitionPage() {
setSubmitting(true)
try {
const requisition = await requisitionsApi.create({ lines: payloadLines })
const requisition = await requisitionsApi.create({ lines: payloadLines, branchId: effectiveBranchId ?? 0 })
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
} catch (err) {
@@ -117,6 +140,30 @@ export default function NewRequisitionPage() {
{!loading && (
<>
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
@@ -1,36 +1,69 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, Save } from "lucide-react"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { LedgerEntry } from "@/types/stock"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
const NO_TAX_RATE = "__none__"
/** Sale-channel document-type prefixes as stored on the ledger (Domain/DocumentTypes.cs). */
const SALES_DOC_TYPES = new Set(["SI", "SSL", "BND"])
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
if (sourceDocType === "SI") return `/dashboard/sales/invoices/${sourceDocId}`
return null
}
function docLabel(sourceDocType: string, sourceDocId: number): string {
const names: Record<string, string> = { GRN: "GRN", SI: "Invoice", SSL: "Slip", BND: "Bundle Sale", ADJ: "Adjustment", TRF: "Transfer", PRET: "Return" }
return `${names[sourceDocType] ?? sourceDocType} #${sourceDocId}`
}
/** A small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Package; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function ItemDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const itemId = Number(params.id)
const [item, setItem] = useState<Item | null>(null)
@@ -40,6 +73,10 @@ export default function ItemDetailPage() {
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
// Profile activity: full recent movement history for this item, across every warehouse.
const [ledger, setLedger] = useState<LedgerEntry[] | null>(null)
const [stockValue, setStockValue] = useState<number | null>(null)
// Basic info form
const [sku, setSku] = useState("")
const [barcode, setBarcode] = useState("")
@@ -77,6 +114,7 @@ export default function ItemDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
function applyItem(data: Item) {
setItem(data)
@@ -127,6 +165,86 @@ export default function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
// Recent activity + stock value: independent of the edit form above, loaded once the
// item id is known. A large page size is fine here — one item's lifetime history.
useEffect(() => {
if (!Number.isFinite(itemId)) return
stockApi.ledger({ itemId, pageSize: 500 }).then((res) => setLedger(res.items)).catch(() => setLedger([]))
stockApi
.onHandList({ itemId, pageSize: 200 })
.then((res) => res.items.filter((r) => r.onHand > 0))
.then((rows) =>
Promise.all(rows.map((r) => stockApi.valuation(itemId, r.warehouseId).catch(() => null)))
)
.then((valuations) => setStockValue(valuations.reduce((sum, v) => sum + (v?.totalValue ?? 0), 0)))
.catch(() => setStockValue(null))
}, [itemId])
const sortedLedger = useMemo(
() => (ledger ?? []).slice().sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
[ledger]
)
/** Cumulative on-hand qty over time, summed across every warehouse — "total stock variation". */
const stockTrend = useMemo(() => {
let running = 0
const points: { date: string; qty: number }[] = []
for (const row of sortedLedger) {
running += row.direction === "In" ? row.qtyBase : -row.qtyBase
points.push({ date: row.createdAt, qty: running })
}
// Thin to at most ~40 points so the chart stays legible on a long history.
const step = Math.max(1, Math.ceil(points.length / 40))
return points.filter((_, i) => i % step === 0 || i === points.length - 1)
}, [sortedLedger])
/** Qty in vs qty out per month, last 6 months with activity. */
const monthlyActivity = useMemo(() => {
const byMonth = new Map<string, { in: number; out: number }>()
for (const row of sortedLedger) {
const key = row.createdAt.slice(0, 7) // YYYY-MM
const bucket = byMonth.get(key) ?? { in: 0, out: 0 }
if (row.direction === "In") bucket.in += row.qtyBase
else bucket.out += row.qtyBase
byMonth.set(key, bucket)
}
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6)
}, [sortedLedger])
// Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the
// blessed way to do it once (rather than on every render) is a one-time init function.
const [cutoff30] = useState(() => Date.now() - 30 * 24 * 60 * 60 * 1000)
const salesQty30d = useMemo(
() =>
sortedLedger
.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType) && new Date(r.createdAt).getTime() >= cutoff30)
.reduce((sum, r) => sum + r.qtyBase, 0),
[sortedLedger, cutoff30]
)
const recentSales = useMemo(
() => sortedLedger.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType)).slice(-8).reverse(),
[sortedLedger]
)
const recentGrns = useMemo(
() => sortedLedger.filter((r) => r.sourceDocType === "GRN").slice(-8).reverse(),
[sortedLedger]
)
const currentStockQty = stockTrend.length > 0 ? stockTrend[stockTrend.length - 1].qty : 0
/** Opens the edit dialog fresh from the last-loaded item, discarding any unsaved
* edits left over from a previous cancelled session. */
function openEdit() {
if (item) applyItem(item)
setErrors({})
setSaveError(null)
setEditOpen(true)
}
function closeEdit() {
if (item) applyItem(item)
setEditOpen(false)
}
async function handleSave() {
if (!item || !etag) return
setSaveError(null)
@@ -152,6 +270,7 @@ export default function ItemDetailPage() {
)
applyItem(result.data)
setEtag(result.etag)
setEditOpen(false)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
@@ -159,6 +278,7 @@ export default function ItemDetailPage() {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
setEditOpen(false)
return
}
const fe = fieldErrors(err)
@@ -212,8 +332,19 @@ export default function ItemDetailPage() {
return (
<div className="flex flex-col gap-6">
<Link
href="/dashboard/products"
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to Items
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Package className="size-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
@@ -228,9 +359,26 @@ export default function ItemDetailPage() {
</div>
</div>
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
<div className="flex items-center gap-2">
<Button size="lg" variant="outline" onClick={openEdit}>
<Pencil className="size-4" />
Edit
</Button>
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
<StatChip
icon={BadgeDollarSign}
label="Stock value"
value={stockValue === null ? "…" : `LKR ${stockValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}`}
/>
<StatChip icon={ShoppingCart} label="Sold, last 30 days" value={salesQty30d.toLocaleString()} />
<StatChip icon={PackageCheck} label="Purchases on record" value={recentGrns.length > 0 ? `${sortedLedger.filter((r) => r.sourceDocType === "GRN").length}` : "0"} />
</div>
{conflict && (
@@ -249,8 +397,128 @@ export default function ItemDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Basic info</h2>
<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">
<TrendingUp className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Total stock variation</h2>
</div>
{ledger === null ? (
<Skeleton className="h-56 w-full" />
) : stockTrend.length > 1 ? (
<LineChart
labels={stockTrend.map((p) => new Date(p.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
datasets={[{ label: `On hand (${uomName(item.baseUomId)})`, data: stockTrend.map((p) => p.qty) }]}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No stock movements recorded yet.</p>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
<ShoppingCart className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Monthly purchases vs sales</h2>
</div>
{ledger === null ? (
<Skeleton className="h-56 w-full" />
) : monthlyActivity.length > 0 ? (
<BarChart
labels={monthlyActivity.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" }))}
datasets={[
{ label: "Purchased", data: monthlyActivity.map(([, v]) => v.in), backgroundColor: "rgba(34,197,94,0.6)" },
{ label: "Sold", data: monthlyActivity.map(([, v]) => v.out), backgroundColor: "rgba(239,68,68,0.6)" },
]}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No activity recorded yet.</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent sales</h2>
{ledger === null ? (
<Skeleton className="h-40 w-full" />
) : recentSales.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sales recorded yet.</p>
) : (
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentSales.map((row) => {
const href = docLink(row.sourceDocType, row.sourceDocId)
return (
<TableRow key={row.ledgerId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
{href ? (
<Link href={href} className="font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
) : (
docLabel(row.sourceDocType, row.sourceDocId)
)}
</TableCell>
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent GRNs (purchases)</h2>
{ledger === null ? (
<Skeleton className="h-40 w-full" />
) : recentGrns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No goods received yet.</p>
) : (
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentGrns.map((row) => (
<TableRow key={row.ledgerId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
</TableCell>
<TableCell className="px-2 py-2">{row.qtyBase.toLocaleString()}</TableCell>
<TableCell className="px-2 py-2">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {item.sku}</DialogTitle>
<DialogDescription>Update this item&apos;s basic info, pricing and classification.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
@@ -465,16 +733,17 @@ export default function ItemDetailPage() {
</Select>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/products")}>
Back
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<p className="text-sm text-muted-foreground">
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
@@ -1,20 +1,36 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
Download,
ListTree,
Package,
Pencil,
Plus,
Ruler,
Search,
} from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ColumnsMenu } from "@/components/ui/columns-menu"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -22,14 +38,40 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = EntityStatus | "All"
type TrackingFilter = TrackingMode | "All"
type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate"
type SortOrder = "asc" | "desc"
const PAGE_SIZE = 25
const COLUMN_OPTIONS = [
{ key: "categoryBrand", label: "Category / Brand" },
{ key: "stockValue", label: "Stock Value" },
{ key: "lastPurchase", label: "Last Purchase" },
{ key: "sales30d", label: "Sales (30d)" },
{ key: "status", label: "Status" },
]
function formatMoney(n: number) {
return `LKR ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) {
return (
<button type="button" className="flex items-center gap-1 hover:text-foreground" onClick={onClick}>
{label}
{active ? (
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
export default function ItemsPage() {
const [items, setItems] = useState<ItemListItem[] | null>(null)
const router = useRouter()
const [items, setItems] = useState<ItemOverview[] | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [brands, setBrands] = useState<Brand[]>([])
const [config, setConfig] = useState<ProductConfig | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -39,71 +81,132 @@ export default function ItemsPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All")
const [trackingMode, setTrackingMode] = useState<TrackingFilter>("All")
const [page, setPage] = useState(1)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [sortKey, setSortKey] = useState<SortKey>("sku")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({})
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, categoryId, trackingMode])
useEffect(() => setPage(1), [query, status, categoryId])
function load() {
setError(null)
itemsApi
.list({
.overview({
page,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId,
trackingMode: trackingMode === "All" ? undefined : trackingMode,
})
.then((res) => {
setItems(res.items)
setPagination(res.pagination)
setSelected(new Set())
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, query, status, categoryId, trackingMode])
useEffect(load, [page, query, status, categoryId])
useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
productConfig().then(setConfig).catch(() => {})
}, [])
// No "list all subcategories" endpoint exists — they're scoped per category — so once
// categories are in, fetch each one's subcategories in parallel to build a flat lookup.
useEffect(() => {
if (categories.length === 0) return
Promise.all(
categories.map((c) =>
categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] }))
)
).then((results) => setSubCategories(results.flatMap((r) => r.items)))
}, [categories])
function categoryName(id: number) {
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
}
function subCategoryName(id: number) {
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}`
}
function brandName(id: number) {
function brandName(id: number | null) {
if (id === null) return "—"
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All"
const showCol = (key: string) => visibleColumns[key] ?? true
function toggleColumn(key: string, next: boolean) {
setVisibleColumns((prev) => ({ ...prev, [key]: next }))
}
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
const sortedItems = useMemo(() => {
if (!items) return null
return [...items].sort((a, b) => {
let cmp: number
switch (sortKey) {
case "stockValue":
cmp = a.stockValue - b.stockValue
break
case "salesQtyLast30Days":
cmp = a.salesQtyLast30Days - b.salesQtyLast30Days
break
case "lastPurchaseDate":
cmp = (a.lastPurchaseDate ? new Date(a.lastPurchaseDate).getTime() : 0) -
(b.lastPurchaseDate ? new Date(b.lastPurchaseDate).getTime() : 0)
break
case "name":
cmp = a.name.localeCompare(b.name)
break
default:
cmp = a.sku.localeCompare(b.sku)
}
return sortOrder === "asc" ? cmp : -cmp
})
}, [items, sortKey, sortOrder])
const allSelected = !!sortedItems && sortedItems.length > 0 && selected.size === sortedItems.length
function toggleAll(checked: boolean) {
setSelected(checked && sortedItems ? new Set(sortedItems.map((i) => i.itemId)) : new Set())
}
function toggleRow(itemId: number, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(itemId)
else next.delete(itemId)
return next
})
}
function handleExport() {
if (!sortedItems) return
const rows = selected.size > 0 ? sortedItems.filter((i) => selected.has(i.itemId)) : sortedItems
downloadCsv(`items-${new Date().toISOString().slice(0, 10)}`, rows, [
{ header: "SKU", value: (i) => i.sku },
{ header: "Name", value: (i) => i.name },
{ header: "Category", value: (i) => categoryName(i.categoryId) },
{ header: "Brand", value: (i) => brandName(i.brandId) },
{ header: "Status", value: (i) => i.status },
{ header: "Stock Qty", value: (i) => i.stockQty },
{ header: "Stock Value (LKR)", value: (i) => i.stockValue.toFixed(2) },
{ header: "Low Stock", value: (i) => (i.isLowStock ? "Yes" : "No") },
{ header: "Last Purchase Date", value: (i) => i.lastPurchaseDate ?? "" },
{ header: "Last Supplier", value: (i) => i.lastSupplierName ?? "" },
{ header: "Sales Qty (30d)", value: (i) => i.salesQtyLast30Days },
])
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Items</h1>
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
</div>
<div className="flex items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
@@ -149,19 +252,8 @@ export default function ItemsPage() {
))}
</SelectContent>
</Select>
<Select<TrackingFilter> value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All tracking modes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All tracking modes</SelectItem>
<SelectItem value="None" className="text-base">None</SelectItem>
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectTrigger className="h-14! w-full sm:w-44 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -170,13 +262,18 @@ export default function ItemsPage() {
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
<ColumnsMenu columns={COLUMN_OPTIONS} visible={visibleColumns} onToggle={toggleColumn} />
<Button variant="outline" size="lg" onClick={handleExport} disabled={!sortedItems || sortedItems.length === 0}>
<Download className="size-4" />
Export{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && items === null && (
{!error && sortedItems === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
@@ -184,7 +281,7 @@ export default function ItemsPage() {
</div>
)}
{!error && items !== null && items.length === 0 && (
{!error && sortedItems !== null && sortedItems.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Package className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
@@ -197,66 +294,111 @@ export default function ItemsPage() {
</div>
)}
{!error && items !== null && items.length > 0 && (
{!error && sortedItems !== null && sortedItems.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.itemId}>
<TableCell className="px-3 py-3.5 font-medium">
<Link href={`/dashboard/products/${item.itemId}`} className="hover:underline">
{item.sku}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
</TableCell>
)}
{config?.brandsEnabled && (
<TableCell className="px-3 py-3.5">
{item.brandId !== null ? brandName(item.brandId) : "—"}
</TableCell>
)}
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-4" />
</Link>
</TableCell>
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3">
<Checkbox checked={allSelected} onCheckedChange={(c) => toggleAll(c === true)} aria-label="Select all" />
</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="SKU" active={sortKey === "sku"} order={sortOrder} onClick={() => toggleSort("sku")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
{showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
{showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
{showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
{showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
{showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{sortedItems.map((item) => (
<TableRow
key={item.itemId}
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/products/${item.itemId}`)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<Checkbox
checked={selected.has(item.itemId)}
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
aria-label={`Select ${item.sku}`}
/>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Package className="size-4 text-primary" />
</div>
{item.sku}
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
{showCol("categoryBrand") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
)}
{showCol("stockValue") && (
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</TableCell>
)}
{showCol("lastPurchase") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.lastPurchaseDate ? (
<>
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
</>
) : (
"—"
)}
</TableCell>
)}
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
{showCol("status") && (
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5" data-no-row-nav>
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
@@ -33,6 +34,7 @@ type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 25
export default function GrnListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
@@ -186,13 +188,19 @@ export default function GrnListPage() {
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grns.map((grn) => {
return (
<TableRow key={grn.grnId}>
<TableRow
key={grn.grnId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}}
>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
@@ -207,7 +215,7 @@ export default function GrnListPage() {
<TableCell className="px-3 py-3.5">
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5" data-no-row-nav>
{grn.status !== "Confirmed" ? (
<span className="text-sm text-muted-foreground">—</span>
) : grn.purchaseInvoiceId ? (
@@ -219,21 +227,6 @@ export default function GrnListPage() {
)}
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
>
<Eye className="size-4" />
</Link>
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
a GRN. A receipt is corrected with a reversing document, never
edited or erased (FR-X-05). */}
</div>
</TableCell>
</TableRow>
)
})}
@@ -202,7 +202,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
}
async function save() {
if (!customerId || !warehouseId || !etag) return
if (!customerId || !warehouseId || !etag || !invoice) return
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.")
return
@@ -216,6 +216,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{
customerId,
warehouseId,
branchId: invoice.branchId,
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
@@ -449,12 +449,14 @@ function NewSalesInvoiceContent() {
async function submit() {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
const payload: CreateSalesInvoiceRequest = {
customerId,
warehouseId,
branchId: effectiveBranchId ?? 0,
invoiceType,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, FileText, Plus, Printer } from "lucide-react"
import { useRouter } from "next/navigation"
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, FileText, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -38,6 +39,7 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
]
export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
@@ -240,7 +242,14 @@ export default function SalesInvoicesPage() {
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.salesInvoiceId} className="hover:bg-muted/40">
<TableRow
key={row.salesInvoiceId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}}
>
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
{row.invoiceNo}
@@ -261,7 +270,7 @@ export default function SalesInvoicesPage() {
)}
</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right">
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<div className="flex items-center justify-end gap-1">
{row.status === "Draft" && (
<Button
@@ -288,14 +297,6 @@ export default function SalesInvoicesPage() {
Pay
</Button>
)}
<Link
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Show invoice ${row.invoiceNo}`}
title="Show invoice"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map"
@@ -31,6 +32,7 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10
export default function RolesPage() {
const router = useRouter()
const [roles, setRoles] = useState<Role[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -182,8 +184,15 @@ export default function RolesPage() {
</TableHeader>
<TableBody>
{roles.map((r) => (
<TableRow key={r.roleId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<TableRow
key={r.roleId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/roles/${r.roleId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className="flex items-start gap-3 rounded-md -m-1 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
@@ -226,17 +235,8 @@ export default function RolesPage() {
})}
</TableCell>
<TableCell className="px-3 py-4">
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${r.code}`}
title={`View ${r.code}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
@@ -32,6 +33,7 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
export default function UsersPage() {
const router = useRouter()
const [users, setUsers] = useState<ManagedUser[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -202,8 +204,15 @@ export default function UsersPage() {
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.userId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<TableRow
key={u.userId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/users/${u.userId}`)
}}
>
<TableCell className="px-3 py-4" data-no-row-nav>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className="font-semibold leading-tight text-foreground group-hover:underline"
@@ -224,16 +233,8 @@ export default function UsersPage() {
{u.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-4">
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
+194 -50
View File
@@ -1,27 +1,46 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, Save } from "lucide-react"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, PackageCheck, Pencil, Save, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { GrnSummary } from "@/types/grn"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
/** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function VendorDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const vendorId = Number(params.id)
const [vendor, setVendor] = useState<Vendor | null>(null)
@@ -39,6 +58,10 @@ export default function VendorDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
// Profile activity: this vendor's recent goods receipts ("buying").
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
// Every other vendor's code, to de-dupe against when the name edit regenerates this
// vendor's own code (mirrors the create dialog's `allVendorCodes`, app/dashboard/vendors/page.tsx).
@@ -56,18 +79,22 @@ export default function VendorDetailPage() {
setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
}
function applyVendor(data: Vendor) {
setVendor(data)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
}
function load() {
setLoadError(null)
vendorsApi
.get(vendorId)
.then(({ data, etag: tag }) => {
setVendor(data)
applyVendor(data)
setEtag(tag)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
setConflict(false)
})
.catch((err) => setLoadError(errorMessage(err)))
@@ -78,6 +105,39 @@ export default function VendorDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vendorId])
/** Opens the edit dialog fresh from the last-loaded vendor, discarding any unsaved
* edits left over from a previous cancelled session. */
function openEdit() {
if (vendor) applyVendor(vendor)
setErrors({})
setSaveError(null)
setEditOpen(true)
}
function closeEdit() {
if (vendor) applyVendor(vendor)
setEditOpen(false)
}
useEffect(() => {
if (!Number.isFinite(vendorId)) return
grnsApi.list({ vendorId, pageSize: 100 }).then((res) => setGrns(res.items)).catch(() => setGrns([]))
}, [vendorId])
const totalPurchased = useMemo(() => (grns ?? []).reduce((sum, g) => sum + g.totalValue, 0), [grns])
const recentGrns = useMemo(
() => (grns ?? []).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 8),
[grns]
)
const monthlyPurchases = useMemo(() => {
const byMonth = new Map<string, number>()
for (const g of grns ?? []) {
const key = g.createdAt.slice(0, 7)
byMonth.set(key, (byMonth.get(key) ?? 0) + g.totalValue)
}
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6)
}, [grns])
async function handleSave() {
setSaveError(null)
const nextErrors: Record<string, string> = {}
@@ -87,11 +147,13 @@ export default function VendorDetailPage() {
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0 || !etag) return
if (!vendor) return
setSaving(true)
try {
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency }, etag)
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, etag)
setVendor(result.data)
setEtag(result.etag)
setEditOpen(false)
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
} catch (err) {
const code = (err as { code?: string })?.code
@@ -99,6 +161,7 @@ export default function VendorDetailPage() {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
setEditOpen(false)
return
}
const fe = fieldErrors(err)
@@ -147,6 +210,14 @@ export default function VendorDetailPage() {
return (
<div className="flex flex-col gap-6">
<Link
href="/dashboard/vendors"
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to Vendors
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
@@ -166,14 +237,30 @@ export default function VendorDetailPage() {
</div>
</div>
<Button
size="lg"
variant={vendor.status === "Active" ? "destructive" : "success"}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
</Button>
<div className="flex items-center gap-2">
<Button size="lg" variant="outline" onClick={openEdit}>
<Pencil className="size-4" />
Edit
</Button>
<Button
size="lg"
variant={vendor.status === "Active" ? "destructive" : "success"}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip
icon={Truck}
label="Last delivery"
value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"}
/>
</div>
{conflict && (
@@ -192,41 +279,98 @@ export default function VendorDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Code (auto-generated)</Label>
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Monthly purchases</h2>
{grns === null ? (
<Skeleton className="h-56 w-full" />
) : monthlyPurchases.length > 0 ? (
<BarChart
labels={monthlyPurchases.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" }))}
datasets={[{ label: "Purchased (LKR)", data: monthlyPurchases.map(([, v]) => v), backgroundColor: "rgba(59,130,246,0.6)" }]}
/>
) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No purchases recorded yet.</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">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>
<div className="flex flex-col gap-2">
<Label className="text-base">Payment terms</Label>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<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} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent purchases (GRNs)</h2>
{grns === null ? (
<Skeleton className="h-56 w-full" />
) : recentGrns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No goods received from this vendor yet.</p>
) : (
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Lines</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentGrns.map((g) => (
<TableRow key={g.grnId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
<Link href={`/dashboard/receiving/grn/${g.grnId}`} className="font-medium text-primary hover:underline">
{g.docNo}
</Link>
</TableCell>
<TableCell className="px-2 py-2">{g.lineCount}</TableCell>
<TableCell className="px-2 py-2">LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/vendors")}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit {vendor.code}</DialogTitle>
<DialogDescription>Update this vendor&apos;s details.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Code (auto-generated)</Label>
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">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>
<div className="flex flex-col gap-2">
<Label className="text-base">Payment terms</Label>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<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} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</div>
</div>
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+37 -53
View File
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -21,7 +22,6 @@ import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -39,9 +39,11 @@ type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25
export default function VendorsPage() {
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -104,11 +106,16 @@ export default function VendorsPage() {
const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function resetForm() {
setName("")
setTerms("")
setTaxReg("")
setCurrency("LKR")
setBranchFilterId(null)
setErrors({})
}
@@ -116,12 +123,13 @@ export default function VendorsPage() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency })
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: effectiveBranchId ?? 0 })
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false)
resetForm()
@@ -198,6 +206,22 @@ export default function VendorsPage() {
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} 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)}>
<SelectTrigger id="v-branch" className="w-full" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -271,7 +295,14 @@ export default function VendorsPage() {
</TableHeader>
<TableBody>
{visibleVendors.map((v) => (
<TableRow key={v.vendorId}>
<TableRow
key={v.vendorId}
className="cursor-pointer hover:bg-muted/40"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/vendors/${v.vendorId}`)
}}
>
<TableCell className="px-3 py-3.5 font-medium">{v.code}</TableCell>
<TableCell className="px-3 py-3.5">{v.name}</TableCell>
<TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell>
@@ -292,55 +323,8 @@ export default function VendorsPage() {
{v.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Dialog>
<DialogTrigger
render={<Button variant="ghost" size="icon-sm" aria-label={`View ${v.code}`} />}
>
<Eye className="size-4" />
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>{v.code}</DialogTitle>
<DialogDescription>{v.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 text-base">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Payment terms</span>
<span className="font-medium">{v.terms ?? "—"}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Tax registration</span>
<span className="font-medium">{v.taxReg ?? "—"}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Currency</span>
<span className="font-medium">{v.currency}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Status</span>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Created</span>
<span className="font-medium">{new Date(v.createdAt).toLocaleString()}</span>
</div>
</div>
<div className="flex justify-center pt-2">
<DialogClose render={<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" />}>Close</DialogClose>
</div>
</DialogContent>
</Dialog>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -2,19 +2,19 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { Bin, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -53,6 +53,7 @@ function generateWarehouseCode(name: string, existingCodes: string[]): string {
}
export default function WarehousesPage() {
const router = useRouter()
const { warehouseBranchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -345,8 +346,15 @@ export default function WarehousesPage() {
</TableHeader>
<TableBody>
{warehouses.map((w) => (
<TableRow key={w.warehouseId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4">
<TableRow
key={w.warehouseId}
className="group align-top hover:bg-muted/20 cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/warehouse/${w.warehouseId}`)
}}
>
<TableCell className="px-4 py-4" data-no-row-nav>
<Link
href={`/dashboard/warehouse/${w.warehouseId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
@@ -369,11 +377,8 @@ export default function WarehousesPage() {
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{binCount(w.warehouseId)}</TableCell>
<TableCell className="px-4 py-4">
<TableCell className="px-4 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/warehouse/${w.warehouseId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${w.code}`} title={`View ${w.code}`}>
<Eye className="size-4" />
</Link>
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(w)} aria-label={`Edit ${w.code}`} title={`Edit ${w.code}`}>
<Pencil className="size-4" />
</Button>
+1 -1
View File
@@ -7,7 +7,7 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-inter);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
+4 -4
View File
@@ -1,10 +1,10 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/providers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
@@ -26,7 +26,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${inter.variable} ${geistMono.variable} h-full antialiased`}
data-scroll-behavior="smooth"
suppressHydrationWarning
>
@@ -116,8 +116,6 @@ const navItems: {
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck },
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
],
},
{
@@ -129,6 +127,9 @@ const navItems: {
chevron: true,
children: [
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
// Reuses the sales.invoices permission code — customers aren't yet their own
// seeded NavItem/Permission on the backend, so this rides along with Sales access.
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
{ title: "Bundle Templates", code: "sales.bundle-templates", href: "/dashboard/sales/bundles/templates", icon: LayoutList },
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Day End", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
@@ -0,0 +1,54 @@
"use client"
import { Columns3 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export interface ColumnOption {
key: string
label: string
/** Columns that must always show (e.g. the name column) omit onToggle to stay unchecked-disabled. */
required?: boolean
}
/** "Columns" button + checklist dropdown for showing/hiding optional table columns. */
export function ColumnsMenu({
columns,
visible,
onToggle,
}: {
columns: ColumnOption[]
visible: Record<string, boolean>
onToggle: (key: string, next: boolean) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="lg" />}>
<Columns3 className="size-4" />
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuLabel>Show columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{columns.map((col) => (
<DropdownMenuCheckboxItem
key={col.key}
checked={col.required ? true : (visible[col.key] ?? true)}
onCheckedChange={(checked) => !col.required && onToggle(col.key, checked === true)}
disabled={col.required}
>
{col.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
+23 -3
View File
@@ -1,15 +1,35 @@
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { Customer } from "@/types/customers"
// One typed client method per customer endpoint. ETag/If-Match on update, PATCH status
// for deactivate (masters are deactivated, not hard-deleted, FR-MD-08).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import { CreateCustomerRequest, Customer, CustomerType, UpdateCustomerRequest } from "@/types/customers"
export interface ListCustomersParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
customerType?: CustomerType
}
export const customersApi = {
list(params: ListCustomersParams = {}): Promise<PagedResponse<Customer>> {
return apiRequest<PagedResponse<Customer>>(`/customers${buildQuery(params)}`)
},
get(customerId: number): Promise<ApiResult<Customer>> {
return apiRequestWithETag<Customer>(`/customers/${customerId}`)
},
create(request: CreateCustomerRequest): Promise<ApiResult<Customer>> {
return apiRequestWithETag<Customer>("/customers", { method: "POST", body: request })
},
update(customerId: number, request: UpdateCustomerRequest, ifMatch: string): Promise<ApiResult<Customer>> {
return apiRequestWithETag<Customer>(`/customers/${customerId}`, { method: "PUT", body: request, ifMatch })
},
updateStatus(customerId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/customers/${customerId}/status`, { method: "PATCH", body: { status } })
},
}
+17
View File
@@ -6,6 +6,7 @@ import {
CreateItemRequest,
Item,
ItemListItem,
ItemOverview,
ItemReorderSetting,
ItemScanResult,
TrackingMode,
@@ -25,11 +26,27 @@ export interface ListItemsParams {
sort?: string
}
export interface ListItemOverviewParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
categoryId?: number
subCategoryId?: number
brandId?: number
}
export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
},
/** Items list enriched with stock value, low-stock, last-purchase and 30-day sales
* aggregates — backs the Items overview table, not the lightweight document pickers. */
overview(params: ListItemOverviewParams = {}): Promise<PagedResponse<ItemOverview>> {
return apiRequest<PagedResponse<ItemOverview>>(`/items/overview${buildQuery(params)}`)
},
get(itemId: number): Promise<ApiResult<Item>> {
return apiRequestWithETag<Item>(`/items/${itemId}`)
},
+31
View File
@@ -0,0 +1,31 @@
/** One column of a CSV export: how to label it and how to read a cell's value from a row. */
export interface CsvColumn<T> {
header: string
value: (row: T) => string | number | boolean | null | undefined
}
function escapeCsvCell(value: string | number | boolean | null | undefined): string {
const text = value === null || value === undefined ? "" : String(value)
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}
/** Builds a CSV string from rows + column definitions — header row first. */
export function toCsv<T>(rows: T[], columns: CsvColumn<T>[]): string {
const header = columns.map((c) => escapeCsvCell(c.header)).join(",")
const lines = rows.map((row) => columns.map((c) => escapeCsvCell(c.value(row))).join(","))
return [header, ...lines].join("\r\n")
}
/** Triggers a browser download of `rows` as a CSV file named `filename` (".csv" appended if missing). */
export function downloadCsv<T>(filename: string, rows: T[], columns: CsvColumn<T>[]): void {
const csv = toCsv(rows, columns)
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename.endsWith(".csv") ? filename : `${filename}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
+18
View File
@@ -0,0 +1,18 @@
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "CU"
* so an empty/punctuation-only name still yields a usable base. Mirrors the vendor code
* generator (lib/vendor-code.ts). */
function customerCodeBase(name: string): string {
const firstWord = name.trim().split(/\s+/)[0] ?? ""
const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "")
return cleaned.slice(0, 10) || "CU"
}
/** Appends a numeric suffix until the code doesn't collide with an existing one — the
* backend enforces global uniqueness but has no generation of its own. */
export function generateCustomerCode(name: string, existingCodes: string[]): string {
const base = `CU-${customerCodeBase(name)}`
if (!existingCodes.includes(base)) return base
let suffix = 2
while (existingCodes.includes(`${base}${suffix}`)) suffix += 1
return `${base}${suffix}`
}
+5 -1
View File
@@ -8,7 +8,11 @@ import path from "path"
//
// Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
// this proxy would reject.
const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:6701"
//
// Uses 127.0.0.1, not "localhost": Node resolves "localhost" to the IPv6 loopback (::1)
// first on this machine, but Kestrel only binds the IPv4 wildcard (0.0.0.0), so every
// proxied request failed with ECONNREFUSED ::1:6701 until this was pinned to IPv4.
const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://127.0.0.1:6701"
const nextConfig: NextConfig = {
turbopack: {
+1 -1
View File
@@ -29,7 +29,7 @@ const config: Config = {
info: "oklch(0.631 0.215 259.262)",
},
fontFamily: {
sans: "var(--font-geist-sans)",
sans: "var(--font-inter)",
mono: "var(--font-geist-mono)",
},
borderRadius: {
+20
View File
@@ -24,3 +24,23 @@ export interface Customer {
createdAt: string
updatedAt: string | null
}
export interface CreateCustomerRequest {
customerCode: string
customerType: CustomerType
name: string
displayName?: string | null
phone?: string | null
email?: string | null
addressLine1?: string | null
addressLine2?: string | null
city?: string | null
country?: string | null
taxRegistrationNo?: string | null
creditLimit: number
creditDays: number
defaultWarehouseId?: number | null
branchId: number
}
export type UpdateCustomerRequest = CreateCustomerRequest
+2
View File
@@ -125,6 +125,8 @@ export interface GrnSummary {
createdAt: string
postedAt: string | null
lineCount: number
/** Sum of every line's `lineTotal` (after discount, incl. VAT) — payable to the vendor. */
totalValue: number
purchaseInvoiceId: number | null
purchaseInvoiceNo: string | null
}
+25
View File
@@ -59,6 +59,28 @@ export interface ItemListItem extends ItemContent {
status: EntityStatus
}
/**
* Item row enriched with cross-module aggregates for the Items overview table
* (`GET /items/overview`) — current FIFO stock value, low-stock flag, last purchase
* (date + supplier), and quantity sold in the trailing 30 days across every sale channel.
*/
export interface ItemOverview {
itemId: number
sku: string
name: string
categoryId: number
subCategoryId: number | null
brandId: number | null
status: EntityStatus
stockQty: number
stockValue: number
isLowStock: boolean
lastPurchaseDate: string | null
lastSupplierId: number | null
lastSupplierName: string | null
salesQtyLast30Days: number
}
export interface ItemReorderSetting {
warehouseId: number
reorderPoint: number
@@ -196,6 +218,9 @@ export interface CreateVendorRequest {
terms?: string | null
taxReg?: string | null
currency: string
/** Required when branching is enabled — resolved client-side from the logged-in user's
* own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */
branchId?: number
}
export type UpdateVendorRequest = CreateVendorRequest
+6
View File
@@ -42,6 +42,9 @@ export interface CreateReqLineInput {
export interface CreateRequisitionRequest {
lines: CreateReqLineInput[]
/** Required when branching is enabled — resolved client-side from the logged-in user's
* own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */
branchId?: number
}
// --- 3.2 RFQs & Quotations ----------------------------------------------------------
@@ -202,6 +205,9 @@ export interface CreatePurchaseOrderRequest {
lines: CreatePoLineInput[]
/** When true the PO is created as an editable/deletable Draft; false (default) issues it straight into PendingApproval. */
saveAsDraft?: boolean
/** Required when branching is enabled — resolved client-side from the logged-in user's
* own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */
branchId?: number
}
/** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */
+4
View File
@@ -45,6 +45,7 @@ export interface SalesInvoiceSummary {
customerId: number
customerSnapshotName: string
warehouseId: number
branchId: number
invoiceType: SalesInvoiceType
status: SalesInvoiceStatus
totals: SalesInvoiceTotals
@@ -137,6 +138,9 @@ export interface CreateSalesInvoiceLineRequest {
export interface CreateSalesInvoiceRequest {
customerId: number
warehouseId: number
/** Required — resolved client-side from the logged-in user's own branch, or picked by
* an Admin viewing "All Branches" (see `useAuth()`). */
branchId: number
invoiceType: SalesInvoiceType
lines: CreateSalesInvoiceLineRequest[]
}