feat: add ItemOverviewDto for enriched item data in items list

feat: implement CustomerDetailPage for detailed customer view and editing

feat: create CustomersPage for listing and managing customers with filters

feat: add ColumnsMenu component for customizable table columns visibility

feat: implement CSV export functionality for data download

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