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:
@@ -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'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'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
@@ -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'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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user