Merge pull request 'Dev' (#86) from Dev into production

Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
2026-09-21 19:03:14 +00:00
45 changed files with 2187 additions and 382 deletions
@@ -30,6 +30,19 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct) CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, 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 /// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary> /// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")] [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> /// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto( public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, 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); int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto( 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) .Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto( .Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, 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)) g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct); .ToListAsync(ct);
@@ -15,6 +15,12 @@ public interface IItemService
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default); 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>?> GetAsync(int itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, 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.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config; using ERPCore.Dtos.Config;
@@ -35,6 +36,7 @@ public sealed class ItemService : IItemService
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLayer> _stockLayers; private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<StockLedger> _stockLedger; private readonly IRepository<StockLedger> _stockLedger;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<Batch> _batches; private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials; private readonly IRepository<Serial> _serials;
private readonly IProductConfigService _config; private readonly IProductConfigService _config;
@@ -52,6 +54,7 @@ public sealed class ItemService : IItemService
IRepository<Warehouse> warehouses, IRepository<Warehouse> warehouses,
IRepository<StockLayer> stockLayers, IRepository<StockLayer> stockLayers,
IRepository<StockLedger> stockLedger, IRepository<StockLedger> stockLedger,
IRepository<GrnLine> grnLines,
IRepository<Batch> batches, IRepository<Batch> batches,
IRepository<Serial> serials, IRepository<Serial> serials,
IProductConfigService config, IProductConfigService config,
@@ -68,6 +71,7 @@ public sealed class ItemService : IItemService
_warehouses = warehouses; _warehouses = warehouses;
_stockLayers = stockLayers; _stockLayers = stockLayers;
_stockLedger = stockLedger; _stockLedger = stockLedger;
_grnLines = grnLines;
_batches = batches; _batches = batches;
_serials = serials; _serials = serials;
_config = config; _config = config;
@@ -108,6 +112,82 @@ public sealed class ItemService : IItemService
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total); 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) public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
{ {
var item = await _items.Query().AsNoTracking() var item = await _items.Query().AsNoTracking()
+1 -1
View File
@@ -3,6 +3,6 @@
"SigningKey": "" "SigningKey": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "" "BaseUrl": "http://localhost:6703"
} }
} }
@@ -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" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useRouter } from "next/navigation"
import { Download, Eye, Plus, Upload } from "lucide-react" import { Download, Plus, Upload } from "lucide-react"
import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance" import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -30,6 +30,7 @@ const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
} }
export default function AttendanceBatchesPage() { export default function AttendanceBatchesPage() {
const router = useRouter()
const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null) const [batches, setBatches] = useState<AttendanceUploadBatch[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | 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">Rows</TableHead>
<TableHead className="h-12 px-3 text-sm">Duplicates/Errors</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">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{batches.map((b) => ( {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 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">{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.rowCountTotal}</TableCell>
<TableCell className="px-3 py-3.5">{b.rowCountDuplicate} / {b.rowCountError}</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"><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> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" 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 { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters" 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] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
export default function EmployeesPage() { export default function EmployeesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null) const [employees, setEmployees] = useState<EmployeeListItem[] | null>(null)
@@ -279,8 +281,15 @@ export default function EmployeesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{employees.map((e) => ( {employees.map((e) => (
<TableRow key={e.employeeId} className="group align-top hover:bg-muted/20"> <TableRow
<TableCell className="px-3 py-4"> 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 <Link
href={`/dashboard/hrm/employees/${e.employeeId}`} 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" 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> </Badge>
</TableCell> </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"> <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}`}> <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" /> <Pencil className="size-4" />
</Link> </Link>
@@ -1,9 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useParams, useRouter } from "next/navigation"
import { useParams } from "next/navigation" import { ChevronLeft, ChevronRight, FileText, Search, X } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll" import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -27,6 +26,7 @@ function money(n: number): string {
export default function PayrollRunDetailPage() { export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter()
const runId = Number(params.id) const runId = Number(params.id)
const [run, setRun] = useState<PayrollRun | null>(null) const [run, setRun] = useState<PayrollRun | null>(null)
@@ -196,7 +196,14 @@ export default function PayrollRunDetailPage() {
const totalDeductions = l.grossSalary - l.netSalary const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId) const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return ( 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"> <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> {l.employeeName} <span className="font-mono text-xs text-muted-foreground">({l.employeeCode})</span>
</TableCell> </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(l.totalAllowances)}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{money(totalDeductions)}</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 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"> <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 && ( {payslip && (
<a <a
href={payslipViewUrl(payslip.payslipId)} href={payslipViewUrl(payslip.payslipId)}
@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Eye, Plus } from "lucide-react" import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll" import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { PayrollRun, PayrollRunStatus } from "@/types/hrm" 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 { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -31,6 +31,7 @@ const STATUS_STYLE: Record<PayrollRunStatus, string> = {
} }
export default function PayrollRunsPage() { export default function PayrollRunsPage() {
const router = useRouter()
const [runs, setRuns] = useState<PayrollRun[] | null>(null) const [runs, setRuns] = useState<PayrollRun[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | 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">Gross</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</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-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{runs.map((r) => ( {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 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-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{r.employeeCount}</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} {r.status}
</Badge> </Badge>
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" 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 { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -33,6 +34,7 @@ const tabs: { value: StatusFilter; label: string }[] = [
] ]
export default function PurchaseInvoicesPage() { export default function PurchaseInvoicesPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null) const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
@@ -209,7 +211,14 @@ export default function PurchaseInvoicesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visibleRows.map((row) => ( {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"> <TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline"> <Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
{row.docNo} {row.docNo}
@@ -235,7 +244,7 @@ export default function PurchaseInvoicesPage() {
)} )}
</div> </div>
</TableCell> </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"> <div className="flex items-center justify-end gap-1">
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -262,14 +271,6 @@ export default function PurchaseInvoicesPage() {
Pay Pay
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -174,7 +174,7 @@ export default function PurchaseOrderDetailPage() {
setSaving(true) setSaving(true)
try { 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) setPo(result.data)
setEtag(result.etag) setEtag(result.etag)
setLines(toDraftLines(result.data)) setLines(toDraftLines(result.data))
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" 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 { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" 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. * still shows the queue to non-admins (read-only) so anyone can see what's waiting.
*/ */
export default function PurchaseOrderApprovalsPage() { export default function PurchaseOrderApprovalsPage() {
const router = useRouter()
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null) const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -156,7 +158,14 @@ export default function PurchaseOrderApprovalsPage() {
{pos.map((po) => { {pos.map((po) => {
const vendor = vendorsById.get(po.vendorId) const vendor = vendorsById.get(po.vendorId)
return ( 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"> <TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> <Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo} {po.docNo}
@@ -173,7 +182,7 @@ export default function PurchaseOrderApprovalsPage() {
{new Date(po.createdAt).toLocaleString()} {new Date(po.createdAt).toLocaleString()}
</div> </div>
</TableCell> </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"> <div className="flex items-center gap-1">
<Button <Button
type="button" type="button"
@@ -195,14 +204,6 @@ export default function PurchaseOrderApprovalsPage() {
<X className="size-4" /> <X className="size-4" />
Reject Reject
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -10,6 +10,7 @@ import { requisitionsApi } from "@/lib/api/requisitions"
import { rfqsApi } from "@/lib/api/rfqs" import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
@@ -19,7 +20,9 @@ import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code" import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement" import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" 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 { Button, buttonVariants } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -66,11 +69,14 @@ function NewPurchaseOrderContent() {
const requisitionId = Number(searchParams.get("requisitionId")) || null const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null const rfqVendorId = Number(searchParams.get("vendorId")) || null
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null) const [uoms, setUoms] = useState<Uom[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | 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 [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -211,6 +217,21 @@ function NewPurchaseOrderContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [requisitionId, rfqId, rfqVendorId]) }, [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>) { function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
} }
@@ -231,6 +252,10 @@ function NewPurchaseOrderContent() {
setHeaderError("Select a vendor.") setHeaderError("Select a vendor.")
return return
} }
if (branchingEnabled && !effectiveBranchId) {
setHeaderError("Select a branch for this purchase order.")
return
}
if (lines.length === 0) { if (lines.length === 0) {
setSubmitError("Add at least one line.") setSubmitError("Add at least one line.")
return return
@@ -268,6 +293,7 @@ function NewPurchaseOrderContent() {
requisitionId: requisitionId ?? (rfqId ? undefined : null), requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines, lines: payloadLines,
saveAsDraft, saveAsDraft,
branchId: effectiveBranchId ?? 0,
}) })
toast.success( toast.success(
"Purchase order created", "Purchase order created",
@@ -369,6 +395,30 @@ function NewPurchaseOrderContent() {
</Dialog> </Dialog>
</div> </div>
</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 && ( {requisitionId && (
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div> <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 { useEffect, useState } from "react"
import Link from "next/link" 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 { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -32,6 +33,7 @@ type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
export default function PurchaseOrdersListPage() { export default function PurchaseOrdersListPage() {
const router = useRouter()
const stats = useDashboardStats() const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null) const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
@@ -203,7 +205,14 @@ export default function PurchaseOrdersListPage() {
{visiblePos.map((po) => { {visiblePos.map((po) => {
const editable = isPoEditable(po.status) const editable = isPoEditable(po.status)
return ( 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"> <TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> <Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo} {po.docNo}
@@ -220,16 +229,8 @@ export default function PurchaseOrdersListPage() {
</TableCell> </TableCell>
)} )}
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</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"> <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 && ( {editable && (
<> <>
<Link <Link
@@ -7,15 +7,19 @@ import { Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateRequisitionLine } from "@/lib/validations/procurement" import { validateRequisitionLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CreateReqLineInput } from "@/types/procurement" import { CreateReqLineInput } from "@/types/procurement"
import { ItemListItem } from "@/types/master-data" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -40,6 +44,7 @@ function emptyLine(): DraftLine {
export default function NewRequisitionPage() { export default function NewRequisitionPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [loadError, setLoadError] = useState<string | 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 [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err))) 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>) { function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) 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.") setSubmitError("Add at least one line.")
return return
} }
if (branchingEnabled && !effectiveBranchId) {
setSubmitError("Select a branch for this requisition.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {} const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) { for (const line of lines) {
@@ -87,7 +110,7 @@ export default function NewRequisitionPage() {
setSubmitting(true) setSubmitting(true)
try { 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.`) toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`) router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
} catch (err) { } catch (err) {
@@ -117,6 +140,30 @@ export default function NewRequisitionPage() {
{!loading && ( {!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 flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
@@ -1,36 +1,69 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" 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 { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates" import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data" import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data" 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. */ /** 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 CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
const NO_TAX_RATE = "__none__" 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 { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" 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() { export default function ItemDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter()
const itemId = Number(params.id) const itemId = Number(params.id)
const [item, setItem] = useState<Item | null>(null) 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 [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
const [loadError, setLoadError] = useState<string | null>(null) 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 // Basic info form
const [sku, setSku] = useState("") const [sku, setSku] = useState("")
const [barcode, setBarcode] = useState("") const [barcode, setBarcode] = useState("")
@@ -77,6 +114,7 @@ export default function ItemDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false)
const [editOpen, setEditOpen] = useState(false)
function applyItem(data: Item) { function applyItem(data: Item) {
setItem(data) setItem(data)
@@ -127,6 +165,86 @@ export default function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId]) }, [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() { async function handleSave() {
if (!item || !etag) return if (!item || !etag) return
setSaveError(null) setSaveError(null)
@@ -152,6 +270,7 @@ export default function ItemDetailPage() {
) )
applyItem(result.data) applyItem(result.data)
setEtag(result.etag) setEtag(result.etag)
setEditOpen(false)
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`) toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
} catch (err) { } catch (err) {
const code = (err as { code?: string })?.code const code = (err as { code?: string })?.code
@@ -159,6 +278,7 @@ export default function ItemDetailPage() {
setConflict(true) setConflict(true)
setSaveError(errorMessage(err)) setSaveError(errorMessage(err))
setSaving(false) setSaving(false)
setEditOpen(false)
return return
} }
const fe = fieldErrors(err) const fe = fieldErrors(err)
@@ -212,8 +332,19 @@ export default function ItemDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1> <h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
@@ -228,9 +359,26 @@ export default function ItemDetailPage() {
</div> </div>
</div> </div>
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}> <div className="flex items-center gap-2">
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"} <Button size="lg" variant="outline" onClick={openEdit}>
</Button> <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> </div>
{conflict && ( {conflict && (
@@ -249,8 +397,128 @@ export default function ItemDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div> <div className="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"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<h2 className="text-base font-semibold text-foreground">Basic info</h2> <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="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label> <Label className="text-base">SKU</Label>
@@ -465,16 +733,17 @@ export default function ItemDetailPage() {
</Select> </Select>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <DialogFooter>
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/products")}> <Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Back Cancel
</Button> </Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}> <Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</Dialog>
<p className="text-sm text-muted-foreground"> <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). {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" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link" 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 { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands" import { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config" import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common" 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 { 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 { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
type TrackingFilter = TrackingMode | "All" type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate"
type SortOrder = "asc" | "desc"
const PAGE_SIZE = 25 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() { 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 [categories, setCategories] = useState<Category[]>([])
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [brands, setBrands] = useState<Brand[]>([]) const [brands, setBrands] = useState<Brand[]>([])
const [config, setConfig] = useState<ProductConfig | null>(null) const [config, setConfig] = useState<ProductConfig | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -39,71 +81,132 @@ export default function ItemsPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All") const [categoryId, setCategoryId] = useState<number | "All">("All")
const [trackingMode, setTrackingMode] = useState<TrackingFilter>("All")
const [page, setPage] = useState(1) 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(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query, status, categoryId, trackingMode]) useEffect(() => setPage(1), [query, status, categoryId])
function load() { function load() {
setError(null) setError(null)
itemsApi itemsApi
.list({ .overview({
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId, categoryId: categoryId === "All" ? undefined : categoryId,
trackingMode: trackingMode === "All" ? undefined : trackingMode,
}) })
.then((res) => { .then((res) => {
setItems(res.items) setItems(res.items)
setPagination(res.pagination) setPagination(res.pagination)
setSelected(new Set())
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
} }
useEffect(load, [page, query, status, categoryId, trackingMode]) useEffect(load, [page, query, status, categoryId])
useEffect(() => { useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
productConfig().then(setConfig).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) { function categoryName(id: number) {
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
} }
function subCategoryName(id: number) { function brandName(id: number | null) {
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}` if (id === null) return "—"
}
function brandName(id: number) {
return brands.find((b) => b.brandId === id)?.name ?? `#${id}` 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Items</h1> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
@@ -149,19 +252,8 @@ export default function ItemsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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")}> <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -170,13 +262,18 @@ export default function ItemsPage() {
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem> <SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent> </SelectContent>
</Select> </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> </div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <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"> <div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <Skeleton key={i} className="h-14 w-full" />
@@ -184,7 +281,7 @@ export default function ItemsPage() {
</div> </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"> <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" /> <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> <p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
@@ -197,66 +294,111 @@ export default function ItemsPage() {
</div> </div>
)} )}
{!error && items !== null && items.length > 0 && ( {!error && sortedItems !== null && sortedItems.length > 0 && (
<> <>
<Table className="text-base"> <div className="overflow-x-auto rounded-xl border">
<TableHeader> <Table className="text-base">
<TableRow> <TableHeader>
<TableHead className="h-12 px-3 text-sm">SKU</TableHead> <TableRow>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-12 w-10 px-3">
<TableHead className="h-12 px-3 text-sm">Category</TableHead> <Checkbox checked={allSelected} onCheckedChange={(c) => toggleAll(c === true)} aria-label="Select all" />
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>} </TableHead>
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</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">Type</TableHead> <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} /></TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> {showCol("categoryBrand") && <TableHead className="h-12 px-3 text-sm">Category{config?.brandsEnabled ? " / Brand" : ""}</TableHead>}
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> {showCol("stockValue") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Stock Value" active={sortKey === "stockValue"} order={sortOrder} onClick={() => toggleSort("stockValue")} /></TableHead>}
</TableRow> {showCol("lastPurchase") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Last Purchase" active={sortKey === "lastPurchaseDate"} order={sortOrder} onClick={() => toggleSort("lastPurchaseDate")} /></TableHead>}
</TableHeader> {showCol("sales30d") && <TableHead className="h-12 px-3 text-sm"><SortableHeader label="Sales (30d)" active={sortKey === "salesQtyLast30Days"} order={sortOrder} onClick={() => toggleSort("salesQtyLast30Days")} /></TableHead>}
<TableBody> {showCol("status") && <TableHead className="h-12 px-3 text-sm">Status</TableHead>}
{items.map((item) => ( <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
<TableRow key={item.itemId}>
<TableCell className="px-3 py-3.5 font-medium">
<Link href={`/dashboard/products/${item.itemId}`} className="hover:underline">
{item.sku}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5">
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
</TableCell>
)}
{config?.brandsEnabled && (
<TableCell className="px-3 py-3.5">
{item.brandId !== null ? brandName(item.brandId) : "—"}
</TableCell>
)}
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {sortedItems.map((item) => (
<TableRow
key={item.itemId}
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/products/${item.itemId}`)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<Checkbox
checked={selected.has(item.itemId)}
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
aria-label={`Select ${item.sku}`}
/>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Package className="size-4 text-primary" />
</div>
{item.sku}
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
{showCol("categoryBrand") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
<p className="text-foreground">{categoryName(item.categoryId)}</p>
{config?.brandsEnabled && <p className="text-xs">{brandName(item.brandId)}</p>}
</TableCell>
)}
{showCol("stockValue") && (
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</TableCell>
)}
{showCol("lastPurchase") && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.lastPurchaseDate ? (
<>
<p className="text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs">{item.lastSupplierName ?? "—"}</p>
</>
) : (
"—"
)}
</TableCell>
)}
{showCol("sales30d") && <TableCell className="px-3 py-3.5">{item.salesQtyLast30Days.toLocaleString()}</TableCell>}
{showCol("status") && (
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5" data-no-row-nav>
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</> </>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" 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 { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -33,6 +34,7 @@ type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
export default function GrnListPage() { export default function GrnListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats() 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">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoice</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">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{grns.map((grn) => { {grns.map((grn) => {
return ( 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"> <TableCell className="px-3 py-3.5">
<Link <Link
href={`/dashboard/receiving/grn/${grn.grnId}`} href={`/dashboard/receiving/grn/${grn.grnId}`}
@@ -207,7 +215,7 @@ export default function GrnListPage() {
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<GrnStatusBadge status={grn.status} /> <GrnStatusBadge status={grn.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5" data-no-row-nav>
{grn.status !== "Confirmed" ? ( {grn.status !== "Confirmed" ? (
<span className="text-sm text-muted-foreground">—</span> <span className="text-sm text-muted-foreground">—</span>
) : grn.purchaseInvoiceId ? ( ) : grn.purchaseInvoiceId ? (
@@ -219,21 +227,6 @@ export default function GrnListPage() {
)} )}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</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> </TableRow>
) )
})} })}
@@ -202,7 +202,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
} }
async function save() { async function save() {
if (!customerId || !warehouseId || !etag) return if (!customerId || !warehouseId || !etag || !invoice) return
if (lines.some((line) => !line.itemId || !line.warehouseId)) { if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.") setError("Select item, UOM and warehouse for every line.")
return return
@@ -216,6 +216,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{ {
customerId, customerId,
warehouseId, warehouseId,
branchId: invoice.branchId,
invoiceType, invoiceType,
lines: lines.map((line) => ({ lines: lines.map((line) => ({
itemId: Number(line.itemId), itemId: Number(line.itemId),
@@ -449,12 +449,14 @@ function NewSalesInvoiceContent() {
async function submit() { async function submit() {
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.") 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.itemId)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.") if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.")
const payload: CreateSalesInvoiceRequest = { const payload: CreateSalesInvoiceRequest = {
customerId, customerId,
warehouseId, warehouseId,
branchId: effectiveBranchId ?? 0,
invoiceType, invoiceType,
lines: lines.map((line) => ({ lines: lines.map((line) => ({
itemId: Number(line.itemId), itemId: Number(line.itemId),
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" 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 { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers" import { customersApi } from "@/lib/api/customers"
@@ -38,6 +39,7 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
] ]
export default function SalesInvoicesPage() { export default function SalesInvoicesPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats() const stats = useDashboardStats()
@@ -240,7 +242,14 @@ export default function SalesInvoicesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visibleRows.map((row) => ( {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"> <TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline"> <Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
{row.invoiceNo} {row.invoiceNo}
@@ -261,7 +270,7 @@ export default function SalesInvoicesPage() {
)} )}
</div> </div>
</TableCell> </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"> <div className="flex items-center justify-end gap-1">
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -288,14 +297,6 @@ export default function SalesInvoicesPage() {
Pay Pay
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" 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 { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -31,6 +32,7 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 10
export default function RolesPage() { export default function RolesPage() {
const router = useRouter()
const [roles, setRoles] = useState<Role[] | null>(null) const [roles, setRoles] = useState<Role[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -182,8 +184,15 @@ export default function RolesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{roles.map((r) => ( {roles.map((r) => (
<TableRow key={r.roleId} className="group align-top hover:bg-muted/20"> <TableRow
<TableCell className="px-3 py-4"> 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 <Link
href={`/dashboard/settings/roles/${r.roleId}`} 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" 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>
<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"> <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 <Link
href={`/dashboard/settings/roles/${r.roleId}`} href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" 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 { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -32,6 +33,7 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
export default function UsersPage() { export default function UsersPage() {
const router = useRouter()
const [users, setUsers] = useState<ManagedUser[] | null>(null) const [users, setUsers] = useState<ManagedUser[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -202,8 +204,15 @@ export default function UsersPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((u) => ( {users.map((u) => (
<TableRow key={u.userId} className="group align-top hover:bg-muted/20"> <TableRow
<TableCell className="px-3 py-4"> 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 <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className="font-semibold leading-tight text-foreground group-hover:underline" className="font-semibold leading-tight text-foreground group-hover:underline"
@@ -224,16 +233,8 @@ export default function UsersPage() {
{u.status} {u.status}
</Badge> </Badge>
</TableCell> </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"> <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 <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
+194 -50
View File
@@ -1,27 +1,46 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" 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 { vendorsApi } from "@/lib/api/vendors"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code" import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data" import { Vendor } from "@/types/master-data"
import { GrnSummary } from "@/types/grn"
import { Button, buttonVariants } from "@/components/ui/button" 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" 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() { export default function VendorDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter()
const vendorId = Number(params.id) const vendorId = Number(params.id)
const [vendor, setVendor] = useState<Vendor | null>(null) const [vendor, setVendor] = useState<Vendor | null>(null)
@@ -39,6 +58,10 @@ export default function VendorDetailPage() {
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = 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 // 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). // 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) : "") setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
} }
function applyVendor(data: Vendor) {
setVendor(data)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
}
function load() { function load() {
setLoadError(null) setLoadError(null)
vendorsApi vendorsApi
.get(vendorId) .get(vendorId)
.then(({ data, etag: tag }) => { .then(({ data, etag: tag }) => {
setVendor(data) applyVendor(data)
setEtag(tag) setEtag(tag)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
setConflict(false) setConflict(false)
}) })
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => setLoadError(errorMessage(err)))
@@ -78,6 +105,39 @@ export default function VendorDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [vendorId]) }, [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() { async function handleSave() {
setSaveError(null) setSaveError(null)
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
@@ -87,11 +147,13 @@ export default function VendorDetailPage() {
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0 || !etag) return if (Object.keys(nextErrors).length > 0 || !etag) return
if (!vendor) return
setSaving(true) setSaving(true)
try { 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) setVendor(result.data)
setEtag(result.etag) setEtag(result.etag)
setEditOpen(false)
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`) toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
} catch (err) { } catch (err) {
const code = (err as { code?: string })?.code const code = (err as { code?: string })?.code
@@ -99,6 +161,7 @@ export default function VendorDetailPage() {
setConflict(true) setConflict(true)
setSaveError(errorMessage(err)) setSaveError(errorMessage(err))
setSaving(false) setSaving(false)
setEditOpen(false)
return return
} }
const fe = fieldErrors(err) const fe = fieldErrors(err)
@@ -147,6 +210,14 @@ export default function VendorDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
@@ -166,14 +237,30 @@ export default function VendorDetailPage() {
</div> </div>
</div> </div>
<Button <div className="flex items-center gap-2">
size="lg" <Button size="lg" variant="outline" onClick={openEdit}>
variant={vendor.status === "Active" ? "destructive" : "success"} <Pencil className="size-4" />
onClick={handleToggleStatus} Edit
disabled={togglingStatus} </Button>
> <Button
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"} size="lg"
</Button> variant={vendor.status === "Active" ? "destructive" : "success"}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip
icon={Truck}
label="Last delivery"
value={recentGrns[0] ? new Date(recentGrns[0].createdAt).toLocaleDateString() : "—"}
/>
</div> </div>
{conflict && ( {conflict && (
@@ -192,41 +279,98 @@ export default function VendorDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)} )}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3 rounded-xl border p-5">
<Label className="text-base">Code (auto-generated)</Label> <h2 className="text-base font-semibold text-foreground">Monthly purchases</h2>
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" /> {grns === null ? (
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} /> <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>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label> <div className="flex flex-col gap-3 rounded-xl border p-5">
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> <h2 className="text-base font-semibold text-foreground">Recent purchases (GRNs)</h2>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> {grns === null ? (
</div> <Skeleton className="h-56 w-full" />
<div className="flex flex-col gap-2"> ) : recentGrns.length === 0 ? (
<Label className="text-base">Payment terms</Label> <p className="py-6 text-center text-sm text-muted-foreground">No goods received from this vendor yet.</p>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} /> ) : (
</div> <Table className="text-sm">
<div className="flex flex-col gap-2"> <TableHeader>
<Label className="text-base">Tax registration</Label> <TableRow>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} /> <TableHead className="h-9 px-2 text-xs">Date</TableHead>
</div> <TableHead className="h-9 px-2 text-xs">Document</TableHead>
<div className="flex flex-col gap-2"> <TableHead className="h-9 px-2 text-xs">Lines</TableHead>
<Label className="text-base">Currency</Label> <TableHead className="h-9 px-2 text-xs">Value</TableHead>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} /> </TableRow>
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} /> </TableHeader>
<TableBody>
{recentGrns.map((g) => (
<TableRow key={g.grnId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
<Link href={`/dashboard/receiving/grn/${g.grnId}`} className="font-medium text-primary hover:underline">
{g.docNo}
</Link>
</TableCell>
<TableCell className="px-2 py-2">{g.lineCount}</TableCell>
<TableCell className="px-2 py-2">LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/vendors")}> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
Cancel <DialogHeader>
</Button> <DialogTitle>Edit {vendor.code}</DialogTitle>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}> <DialogDescription>Update this vendor&apos;s details.</DialogDescription>
<Save className="size-5" /> </DialogHeader>
{saving ? "Saving…" : "Save changes"} <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
</Button> <div className="flex flex-col gap-2">
</div> <Label className="text-base">Code (auto-generated)</Label>
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Payment terms</Label>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Currency</Label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</div>
</div>
<DialogFooter>
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+37 -53
View File
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" 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 { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters" 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 { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
@@ -39,9 +39,11 @@ type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
export default function VendorsPage() { 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 [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | 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 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() { function resetForm() {
setName("") setName("")
setTerms("") setTerms("")
setTaxReg("") setTaxReg("")
setCurrency("LKR") setCurrency("LKR")
setBranchFilterId(null)
setErrors({}) setErrors({})
} }
@@ -116,12 +123,13 @@ export default function VendorsPage() {
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Vendor name is required" if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required" if (!currency.trim()) nextErrors.currency = "Currency is required"
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
setSubmitting(true) setSubmitting(true)
try { 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}`) toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
setOpen(false) setOpen(false)
resetForm() 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} /> <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]} /> <FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</Field> </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> </FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}> <Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -271,7 +295,14 @@ export default function VendorsPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visibleVendors.map((v) => ( {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 font-medium">{v.code}</TableCell>
<TableCell className="px-3 py-3.5">{v.name}</TableCell> <TableCell className="px-3 py-3.5">{v.name}</TableCell>
<TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell> <TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell>
@@ -292,55 +323,8 @@ export default function VendorsPage() {
{v.status} {v.status}
</Badge> </Badge>
</TableCell> </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"> <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 <Link
href={`/dashboard/vendors/${v.vendorId}`} href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -2,19 +2,19 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" 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 { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { Bin, Warehouse } from "@/types/master-data" import { Bin, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" 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 { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -53,6 +53,7 @@ function generateWarehouseCode(name: string, existingCodes: string[]): string {
} }
export default function WarehousesPage() { export default function WarehousesPage() {
const router = useRouter()
const { warehouseBranchingEnabled } = useAuth() const { warehouseBranchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -345,8 +346,15 @@ export default function WarehousesPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{warehouses.map((w) => ( {warehouses.map((w) => (
<TableRow key={w.warehouseId} className="group align-top hover:bg-muted/20"> <TableRow
<TableCell className="px-4 py-4"> 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 <Link
href={`/dashboard/warehouse/${w.warehouseId}`} 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" 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 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"> <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}`}> <Button variant="ghost" size="icon-sm" onClick={() => openEdit(w)} aria-label={`Edit ${w.code}`} title={`Edit ${w.code}`}>
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
+1 -1
View File
@@ -7,7 +7,7 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-inter);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
+4 -4
View File
@@ -1,10 +1,10 @@
import type { Metadata } from "next"; 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 { Providers } from "@/components/providers";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const inter = Inter({
variable: "--font-geist-sans", variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
}); });
@@ -26,7 +26,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${inter.variable} ${geistMono.variable} h-full antialiased`}
data-scroll-behavior="smooth" data-scroll-behavior="smooth"
suppressHydrationWarning suppressHydrationWarning
> >
@@ -116,8 +116,6 @@ const navItems: {
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart }, { 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: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck }, { 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, chevron: true,
children: [ children: [
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText }, { 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: "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: "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 }, { 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" // One typed client method per customer endpoint. ETag/If-Match on update, PATCH status
import { PagedResponse } from "@/types/common" // for deactivate (masters are deactivated, not hard-deleted, FR-MD-08).
import { Customer } from "@/types/customers" 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 { export interface ListCustomersParams {
page?: number page?: number
pageSize?: number pageSize?: number
q?: string q?: string
status?: EntityStatus
customerType?: CustomerType
} }
export const customersApi = { export const customersApi = {
list(params: ListCustomersParams = {}): Promise<PagedResponse<Customer>> { list(params: ListCustomersParams = {}): Promise<PagedResponse<Customer>> {
return apiRequest<PagedResponse<Customer>>(`/customers${buildQuery(params)}`) 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, CreateItemRequest,
Item, Item,
ItemListItem, ItemListItem,
ItemOverview,
ItemReorderSetting, ItemReorderSetting,
ItemScanResult, ItemScanResult,
TrackingMode, TrackingMode,
@@ -25,11 +26,27 @@ export interface ListItemsParams {
sort?: string sort?: string
} }
export interface ListItemOverviewParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
categoryId?: number
subCategoryId?: number
brandId?: number
}
export const itemsApi = { export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> { list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`) 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>> { get(itemId: number): Promise<ApiResult<Item>> {
return apiRequestWithETag<Item>(`/items/${itemId}`) 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 // Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
// this proxy would reject. // 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 = { const nextConfig: NextConfig = {
turbopack: { turbopack: {
+1 -1
View File
@@ -29,7 +29,7 @@ const config: Config = {
info: "oklch(0.631 0.215 259.262)", info: "oklch(0.631 0.215 259.262)",
}, },
fontFamily: { fontFamily: {
sans: "var(--font-geist-sans)", sans: "var(--font-inter)",
mono: "var(--font-geist-mono)", mono: "var(--font-geist-mono)",
}, },
borderRadius: { borderRadius: {
+20
View File
@@ -24,3 +24,23 @@ export interface Customer {
createdAt: string createdAt: string
updatedAt: string | null 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 createdAt: string
postedAt: string | null postedAt: string | null
lineCount: number lineCount: number
/** Sum of every line's `lineTotal` (after discount, incl. VAT) — payable to the vendor. */
totalValue: number
purchaseInvoiceId: number | null purchaseInvoiceId: number | null
purchaseInvoiceNo: string | null purchaseInvoiceNo: string | null
} }
+25
View File
@@ -59,6 +59,28 @@ export interface ItemListItem extends ItemContent {
status: EntityStatus 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 { export interface ItemReorderSetting {
warehouseId: number warehouseId: number
reorderPoint: number reorderPoint: number
@@ -196,6 +218,9 @@ export interface CreateVendorRequest {
terms?: string | null terms?: string | null
taxReg?: string | null taxReg?: string | null
currency: string 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 export type UpdateVendorRequest = CreateVendorRequest
+6
View File
@@ -42,6 +42,9 @@ export interface CreateReqLineInput {
export interface CreateRequisitionRequest { export interface CreateRequisitionRequest {
lines: CreateReqLineInput[] 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 ---------------------------------------------------------- // --- 3.2 RFQs & Quotations ----------------------------------------------------------
@@ -202,6 +205,9 @@ export interface CreatePurchaseOrderRequest {
lines: CreatePoLineInput[] lines: CreatePoLineInput[]
/** When true the PO is created as an editable/deletable Draft; false (default) issues it straight into PendingApproval. */ /** When true the PO is created as an editable/deletable Draft; false (default) issues it straight into PendingApproval. */
saveAsDraft?: boolean 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. */ /** 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 customerId: number
customerSnapshotName: string customerSnapshotName: string
warehouseId: number warehouseId: number
branchId: number
invoiceType: SalesInvoiceType invoiceType: SalesInvoiceType
status: SalesInvoiceStatus status: SalesInvoiceStatus
totals: SalesInvoiceTotals totals: SalesInvoiceTotals
@@ -137,6 +138,9 @@ export interface CreateSalesInvoiceLineRequest {
export interface CreateSalesInvoiceRequest { export interface CreateSalesInvoiceRequest {
customerId: number customerId: number
warehouseId: 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 invoiceType: SalesInvoiceType
lines: CreateSalesInvoiceLineRequest[] lines: CreateSalesInvoiceLineRequest[]
} }