diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs index 5dcb584..0a17b3f 100644 --- a/Backend/ERPCore/Controllers/ItemsController.cs +++ b/Backend/ERPCore/Controllers/ItemsController.cs @@ -30,6 +30,19 @@ public sealed class ItemsController : ApiControllerBase CancellationToken ct) => Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct)); + /// Items list enriched with stock value, low-stock, last-purchase and + /// trailing-30-day sales aggregates, for the Items overview table. + [HttpGet("overview")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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)); + /// Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial /// barcode/no (optionally scoped to first). [HttpGet("scan")] diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index b05e626..84b47cd 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -22,7 +22,7 @@ public sealed record GrnDto( /// Row shape for GET /grns — line count instead of the lines themselves. public sealed record GrnSummaryDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, - int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, + int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue, int? PurchaseInvoiceId, string? PurchaseInvoiceNo); public sealed record CreatedLayerDto( diff --git a/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs b/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs new file mode 100644 index 0000000..a6fbb99 --- /dev/null +++ b/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs @@ -0,0 +1,18 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Items; + +/// +/// 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 's GET /items — that list +/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates +/// computed on every keystroke. +/// +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); diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 36345cd..dba30c1 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -100,7 +100,7 @@ public sealed class GrnService : IGrnService .Skip(query.Skip).Take(query.PageSize) .Select(g => new GrnSummaryDto( g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, - g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, + g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal), g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null)) .ToListAsync(ct); diff --git a/Backend/ERPCore/Services/Interfaces/IItemService.cs b/Backend/ERPCore/Services/Interfaces/IItemService.cs index c438075..8d097f7 100644 --- a/Backend/ERPCore/Services/Interfaces/IItemService.cs +++ b/Backend/ERPCore/Services/Interfaces/IItemService.cs @@ -15,6 +15,12 @@ public interface IItemService PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, TrackingMode? trackingMode, CancellationToken ct = default); + /// Same filters as , enriched with stock value, low-stock, + /// last-purchase and trailing-30-day sales aggregates for the Items list page. + Task> GetOverviewAsync( + PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, + CancellationToken ct = default); + Task?> GetAsync(int itemId, CancellationToken ct = default); Task> CreateAsync(CreateItemRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 829ddba..6c44d6c 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -1,4 +1,5 @@ using ERPCore.Common.Http; +using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Config; @@ -35,6 +36,7 @@ public sealed class ItemService : IItemService private readonly IRepository _warehouses; private readonly IRepository _stockLayers; private readonly IRepository _stockLedger; + private readonly IRepository _grnLines; private readonly IRepository _batches; private readonly IRepository _serials; private readonly IProductConfigService _config; @@ -52,6 +54,7 @@ public sealed class ItemService : IItemService IRepository warehouses, IRepository stockLayers, IRepository stockLedger, + IRepository grnLines, IRepository batches, IRepository serials, IProductConfigService config, @@ -68,6 +71,7 @@ public sealed class ItemService : IItemService _warehouses = warehouses; _stockLayers = stockLayers; _stockLedger = stockLedger; + _grnLines = grnLines; _batches = batches; _serials = serials; _config = config; @@ -108,6 +112,82 @@ public sealed class ItemService : IItemService return PagedResponse.Create(rows, query.Page, query.PageSize, total); } + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + public async Task?> GetAsync(int itemId, CancellationToken ct = default) { var item = await _items.Query().AsNoTracking() diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json index a1b8a7a..dc6cf26 100644 --- a/Backend/ERPCore/appsettings.Production.json +++ b/Backend/ERPCore/appsettings.Production.json @@ -3,6 +3,6 @@ "SigningKey": "" }, "AuthHex": { - "BaseUrl": "" + "BaseUrl": "http://localhost:6703" } } diff --git a/Frontend/erp-system/app/dashboard/customers/[id]/page.tsx b/Frontend/erp-system/app/dashboard/customers/[id]/page.tsx new file mode 100644 index 0000000..f58ef1b --- /dev/null +++ b/Frontend/erp-system/app/dashboard/customers/[id]/page.tsx @@ -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 ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ) +} + +export default function CustomerDetailPage() { + const params = useParams<{ id: string }>() + const customerId = Number(params.id) + + const [customer, setCustomer] = useState(null) + const [etag, setEtag] = useState(null) + const [loadError, setLoadError] = useState(null) + + const [customerCode, setCustomerCode] = useState("") + const [customerType, setCustomerType] = useState("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>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(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(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() + 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 = {} + 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 ( +
+
{loadError}
+ + Back to customers + +
+ ) + } + + if (!customer) { + return ( +
+ + +
+ ) + } + + return ( +
+ + + Back to Customers + + +
+
+
+ +
+
+
+

{customer.customerCode}

+ + {customer.status} + +
+

{customer.displayName ?? customer.name}

+
+
+ +
+ + +
+
+ +
+ + + +
+ + {conflict && ( +
+ +
+

{saveError ?? "This customer was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+
+

Monthly sales

+ {invoices === null ? ( + + ) : monthlySales.length > 0 ? ( + 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)" }]} + /> + ) : ( +

No sales recorded yet.

+ )} +
+ +
+

Recent sales invoices

+ {invoices === null ? ( + + ) : recentInvoices.length === 0 ? ( +

No sales to this customer yet.

+ ) : ( + + + + Date + Invoice + Status + Total + + + + {recentInvoices.map((inv) => ( + + {new Date(inv.invoiceDate).toLocaleDateString()} + + + {inv.invoiceNo} + + + {inv.status} + LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + ))} + +
+ )} +
+
+ + (open ? openEdit() : closeEdit())}> + + + Edit {customer.customerCode} + Update this customer's details. + +
+
+ + setCustomerCode(e.target.value)} aria-invalid={!!errors.customerCode} className="h-12 text-base" disabled={conflict} /> + +
+
+ + value={customerType} onValueChange={(v) => v && setCustomerType(v)} disabled={conflict}> + + + + + B2C — individual + B2B — business + + +
+
+ + setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setCity(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + + + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/customers/page.tsx b/Frontend/erp-system/app/dashboard/customers/page.tsx new file mode 100644 index 0000000..a057a5d --- /dev/null +++ b/Frontend/erp-system/app/dashboard/customers/page.tsx @@ -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(null) + const [branches, setBranches] = useState([]) + const [branchFilterId, setBranchFilterId] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [customerType, setCustomerType] = useState("All") + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [newCustomerType, setNewCustomerType] = useState("B2C") + const [phone, setPhone] = useState("") + const [email, setEmail] = useState("") + const [creditLimit, setCreditLimit] = useState("0") + const [creditDays, setCreditDays] = useState("0") + const [errors, setErrors] = useState>({}) + 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([]) + const [actionPendingId, setActionPendingId] = useState(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 = {} + 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 ( +
+
+
+

Customers

+

Customer master data used by sales invoices, slips and bundles.

+
+ + + + + New Customer + + } + /> + + + New customer + Its code is generated from the name — everything else can be filled in later from its profile. + + + + Name + setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} /> + + + + Code (auto-generated) + + + + Customer type + value={newCustomerType} onValueChange={(v) => v && setNewCustomerType(v)}> + + + + + B2C — individual + B2B — business + + + +
+ + Phone (optional) + setPhone(e.target.value)} placeholder="0771234567" /> + + + Email (optional) + setEmail(e.target.value)} placeholder="customer@example.com" /> + +
+
+ + Credit limit (LKR) + setCreditLimit(e.target.value)} /> + + + Credit days + setCreditDays(e.target.value)} /> + +
+ {showBranchPicker && ( + + Branch + value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}> + + + + + {branches.map((b) => ( + {b.name} + ))} + + + + + )} +
+
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search code or name…" + className="h-14 w-full pl-11 text-base" + aria-label="Search customers" + /> +
+ value={customerType} onValueChange={(v) => setCustomerType(v ?? "All")}> + + + + + All types + B2C + B2B + + + value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ + {error && ( +
{error}
+ )} + + {!error && customers === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && customers !== null && customers.length === 0 && ( +
+ +

+ {hasFilters ? "No customers match your search/filter." : "No customers yet."} +

+
+ )} + + {!error && customers !== null && customers.length > 0 && ( + <> + + + + Code + Name + Type + Phone + Credit limit + Status + Actions + + + + {customers.map((c) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/customers/${c.customerId}`) + }} + > + {c.customerCode} + {c.displayName ?? c.name} + + {c.customerType} + + {c.phone ?? "—"} + LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + + {c.status} + + + +
+ + + + + + + } + > + + + handleDeactivate(c)} + /> + +
+
+
+ ))} +
+
+ + {pagination && } + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx b/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx index d4c4af9..222aa87 100644 --- a/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx @@ -1,8 +1,8 @@ "use client" import { useEffect, useState } from "react" -import Link from "next/link" -import { Download, Eye, Plus, Upload } from "lucide-react" +import { useRouter } from "next/navigation" +import { Download, Plus, Upload } from "lucide-react" import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance" import { errorMessage } from "@/lib/error-map" @@ -30,6 +30,7 @@ const STATUS_STYLE: Record = { } export default function AttendanceBatchesPage() { + const router = useRouter() const [batches, setBatches] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -119,20 +120,20 @@ export default function AttendanceBatchesPage() { Rows Duplicates/Errors Status - Actions {batches.map((b) => ( - + router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)} + > {b.docNo} {new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()} {b.rowCountTotal} {b.rowCountDuplicate} / {b.rowCountError} {b.status} - - - ))} diff --git a/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx b/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx index 65266b4..d5e2ae7 100644 --- a/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react" +import { useRouter } from "next/navigation" +import { ChevronLeft, ChevronRight, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react" import { employeesApi } from "@/lib/api/employees" import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters" @@ -30,6 +31,7 @@ const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] export default function EmployeesPage() { + const router = useRouter() const { activeBranchId, branchingEnabled } = useAuth() const [employees, setEmployees] = useState(null) @@ -279,8 +281,15 @@ export default function EmployeesPage() { {employees.map((e) => ( - - + { + if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/hrm/employees/${e.employeeId}`) + }} + > + - +
- - - diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx index 8a83b49..c43f5fa 100644 --- a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx @@ -1,9 +1,8 @@ "use client" import { useEffect, useState } from "react" -import Link from "next/link" -import { useParams } from "next/navigation" -import { ChevronLeft, ChevronRight, Eye, FileText, Search, X } from "lucide-react" +import { useParams, useRouter } from "next/navigation" +import { ChevronLeft, ChevronRight, FileText, Search, X } from "lucide-react" import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll" import { errorMessage } from "@/lib/error-map" @@ -27,6 +26,7 @@ function money(n: number): string { export default function PayrollRunDetailPage() { const params = useParams<{ id: string }>() + const router = useRouter() const runId = Number(params.id) const [run, setRun] = useState(null) @@ -196,7 +196,14 @@ export default function PayrollRunDetailPage() { const totalDeductions = l.grossSalary - l.netSalary const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId) return ( - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) + }} + > {l.employeeName} ({l.employeeCode}) @@ -205,16 +212,8 @@ export default function PayrollRunDetailPage() { {money(l.totalAllowances)} {money(totalDeductions)} {money(l.netSalary)} - +
- - - {payslip && ( = { } export default function PayrollRunsPage() { + const router = useRouter() const [runs, setRuns] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -152,12 +153,15 @@ export default function PayrollRunsPage() { Gross Net Status - Actions {runs.map((r) => ( - + router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)} + > {r.docNo} {r.periodMonth.toString().padStart(2, "0")}/{r.periodYear} {r.employeeCount} @@ -168,18 +172,6 @@ export default function PayrollRunsPage() { {r.status} - -
- - - -
-
))}
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx index e732db7..090440e 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react" +import { useRouter } from "next/navigation" +import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText } from "lucide-react" import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { branchesHrmApi } from "@/lib/api/hrm-masters" @@ -33,6 +34,7 @@ const tabs: { value: StatusFilter; label: string }[] = [ ] export default function PurchaseInvoicesPage() { + const router = useRouter() const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const [rows, setRows] = useState(null) const [branches, setBranches] = useState([]) @@ -209,7 +211,14 @@ export default function PurchaseInvoicesPage() { {visibleRows.map((row) => ( - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`) + }} + > {row.docNo} @@ -235,7 +244,7 @@ export default function PurchaseInvoicesPage() { )}
- +
{row.status === "Draft" && (
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index dd7b6b0..f047adc 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -174,7 +174,7 @@ export default function PurchaseOrderDetailPage() { setSaving(true) try { - const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag) + const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines, branchId: po.branchId }, etag) setPo(result.data) setEtag(result.etag) setLines(toDraftLines(result.data)) diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/approvals/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/approvals/page.tsx index 0de4adc..8311be1 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/approvals/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/approvals/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react" +import { useRouter } from "next/navigation" +import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react" import { purchaseOrdersApi } from "@/lib/api/purchase-orders" import { vendorsApi } from "@/lib/api/vendors" @@ -34,6 +35,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges" * still shows the queue to non-admins (read-only) so anyone can see what's waiting. */ export default function PurchaseOrderApprovalsPage() { + const router = useRouter() const { isAdmin } = useAuth() const [pos, setPos] = useState(null) @@ -156,7 +158,14 @@ export default function PurchaseOrderApprovalsPage() { {pos.map((po) => { const vendor = vendorsById.get(po.vendorId) return ( - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) + }} + > {po.docNo} @@ -173,7 +182,7 @@ export default function PurchaseOrderApprovalsPage() { {new Date(po.createdAt).toLocaleString()}
- +
- - -
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index 03f99f2..3aa4b0c 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -10,6 +10,7 @@ import { requisitionsApi } from "@/lib/api/requisitions" import { rfqsApi } from "@/lib/api/rfqs" import { vendorsApi } from "@/lib/api/vendors" import { warehousesApi } from "@/lib/api/warehouses" +import { branchesHrmApi } from "@/lib/api/hrm-masters" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" import { baseUomLabel } from "@/lib/uom-label" @@ -19,7 +20,9 @@ import { cn } from "@/lib/utils" import { generateVendorCode } from "@/lib/vendor-code" import { CreatePoLineInput } from "@/types/procurement" import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" +import { Branch } from "@/types/hrm" +import { useAuth } from "@/components/auth/AuthProvider" import { Button, buttonVariants } from "@/components/ui/button" import { Dialog, @@ -66,11 +69,14 @@ function NewPurchaseOrderContent() { const requisitionId = Number(searchParams.get("requisitionId")) || null const rfqId = Number(searchParams.get("rfqId")) || null const rfqVendorId = Number(searchParams.get("vendorId")) || null + const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth() const [items, setItems] = useState(null) const [uoms, setUoms] = useState(null) const [warehouses, setWarehouses] = useState(null) const [vendors, setVendors] = useState(null) + const [branches, setBranches] = useState([]) + const [branchFilterId, setBranchFilterId] = useState(null) const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId) const [loadError, setLoadError] = useState(null) @@ -211,6 +217,21 @@ function NewPurchaseOrderContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [requisitionId, rfqId, rfqVendorId]) + // Admin-only branch picker — non-admins never see this control, their own employee + // branch is used directly (mirrors the New GRN / New Sale pattern). + useEffect(() => { + if (branchingEnabled && isAdmin) { + branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) + } + }, [branchingEnabled, isAdmin]) + + // An Admin on a specific header branch is pinned to it here too (shown, not editable) — + // the dropdown only appears when the header itself is on "All Branches", so there's no + // branch to pin to and the Admin must pick one explicitly for this PO. + const lockedBranchId = isAdmin ? activeBranchId : myBranchId + const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId + const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null + function updateLine(key: string, patch: Partial) { setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) } @@ -231,6 +252,10 @@ function NewPurchaseOrderContent() { setHeaderError("Select a vendor.") return } + if (branchingEnabled && !effectiveBranchId) { + setHeaderError("Select a branch for this purchase order.") + return + } if (lines.length === 0) { setSubmitError("Add at least one line.") return @@ -268,6 +293,7 @@ function NewPurchaseOrderContent() { requisitionId: requisitionId ?? (rfqId ? undefined : null), lines: payloadLines, saveAsDraft, + branchId: effectiveBranchId ?? 0, }) toast.success( "Purchase order created", @@ -369,6 +395,30 @@ function NewPurchaseOrderContent() { + {branchingEnabled && isAdmin && ( +
+ + {showBranchPicker ? ( + value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}> + + + + + {branches.map((b) => ( + + {b.name} + + ))} + + + ) : ( +
+ {activeBranchName ?? `Branch #${lockedBranchId}`} +
+ )} +
+ )} + {requisitionId && (
From Requisition #{requisitionId}
)} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx index cf112f3..825dfd3 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react" +import { useRouter } from "next/navigation" +import { ChevronLeft, ChevronRight, Clock, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { vendorsApi } from "@/lib/api/vendors" @@ -32,6 +33,7 @@ type StatusFilter = PurchaseOrderStatus | "All" const PAGE_SIZE = 25 export default function PurchaseOrdersListPage() { + const router = useRouter() const stats = useDashboardStats() const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const [pos, setPos] = useState(null) @@ -203,7 +205,14 @@ export default function PurchaseOrdersListPage() { {visiblePos.map((po) => { const editable = isPoEditable(po.status) return ( - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) + }} + > {po.docNo} @@ -220,16 +229,8 @@ export default function PurchaseOrdersListPage() { )} {new Date(po.createdAt).toLocaleString()} - +
- - - {editable && ( <> (null) const [loadError, setLoadError] = useState(null) @@ -47,11 +52,25 @@ export default function NewRequisitionPage() { const [lineErrors, setLineErrors] = useState>>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) + const [branches, setBranches] = useState([]) + const [branchFilterId, setBranchFilterId] = useState(null) useEffect(() => { itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err))) }, []) + // Admin-only branch picker — non-admins never see this control, their own employee + // branch is used directly (mirrors the New GRN / New Sale pattern). + useEffect(() => { + if (branchingEnabled && isAdmin) { + branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) + } + }, [branchingEnabled, isAdmin]) + + const lockedBranchId = isAdmin ? activeBranchId : myBranchId + const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId + const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null + function updateLine(key: string, patch: Partial) { setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) } @@ -67,6 +86,10 @@ export default function NewRequisitionPage() { setSubmitError("Add at least one line.") return } + if (branchingEnabled && !effectiveBranchId) { + setSubmitError("Select a branch for this requisition.") + return + } const nextLineErrors: Record> = {} for (const line of lines) { @@ -87,7 +110,7 @@ export default function NewRequisitionPage() { setSubmitting(true) try { - const requisition = await requisitionsApi.create({ lines: payloadLines }) + const requisition = await requisitionsApi.create({ lines: payloadLines, branchId: effectiveBranchId ?? 0 }) toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`) router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`) } catch (err) { @@ -117,6 +140,30 @@ export default function NewRequisitionPage() { {!loading && ( <> + {branchingEnabled && isAdmin && ( +
+ + {showBranchPicker ? ( + value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}> + + + + + {branches.map((b) => ( + + {b.name} + + ))} + + + ) : ( +
+ {activeBranchName ?? `Branch #${lockedBranchId}`} +
+ )} +
+ )} +

Lines

diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index e678aa3..5679930 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -1,36 +1,69 @@ "use client" -import { useEffect, useState } from "react" -import { useParams, useRouter } from "next/navigation" +import { useEffect, useMemo, useState } from "react" +import { useParams } from "next/navigation" import Link from "next/link" -import { AlertTriangle, Save } from "lucide-react" +import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" import { uomsApi } from "@/lib/api/uoms" import { taxRatesApi } from "@/lib/api/tax-rates" import { warehousesApi } from "@/lib/api/warehouses" +import { stockApi } from "@/lib/api/stock" import { errorMessage, fieldErrors } from "@/lib/error-map" import { validateItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data" +import { LedgerEntry } from "@/types/stock" /** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */ const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"] const NO_TAX_RATE = "__none__" +/** Sale-channel document-type prefixes as stored on the ledger (Domain/DocumentTypes.cs). */ +const SALES_DOC_TYPES = new Set(["SI", "SSL", "BND"]) import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +import LineChart from "@/components/ui/line-chart" +import BarChart from "@/components/ui/bar-chart" + +function docLink(sourceDocType: string, sourceDocId: number): string | null { + if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}` + if (sourceDocType === "SI") return `/dashboard/sales/invoices/${sourceDocId}` + return null +} + +function docLabel(sourceDocType: string, sourceDocId: number): string { + const names: Record = { 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 ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ) +} export default function ItemDetailPage() { const params = useParams<{ id: string }>() - const router = useRouter() const itemId = Number(params.id) const [item, setItem] = useState(null) @@ -40,6 +73,10 @@ export default function ItemDetailPage() { const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) const [loadError, setLoadError] = useState(null) + // Profile activity: full recent movement history for this item, across every warehouse. + const [ledger, setLedger] = useState(null) + const [stockValue, setStockValue] = useState(null) + // Basic info form const [sku, setSku] = useState("") const [barcode, setBarcode] = useState("") @@ -77,6 +114,7 @@ export default function ItemDetailPage() { const [saveError, setSaveError] = useState(null) const [saving, setSaving] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false) + const [editOpen, setEditOpen] = useState(false) function applyItem(data: Item) { setItem(data) @@ -127,6 +165,86 @@ export default function ItemDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [itemId]) + // Recent activity + stock value: independent of the edit form above, loaded once the + // item id is known. A large page size is fine here — one item's lifetime history. + useEffect(() => { + if (!Number.isFinite(itemId)) return + stockApi.ledger({ itemId, pageSize: 500 }).then((res) => setLedger(res.items)).catch(() => setLedger([])) + stockApi + .onHandList({ itemId, pageSize: 200 }) + .then((res) => res.items.filter((r) => r.onHand > 0)) + .then((rows) => + Promise.all(rows.map((r) => stockApi.valuation(itemId, r.warehouseId).catch(() => null))) + ) + .then((valuations) => setStockValue(valuations.reduce((sum, v) => sum + (v?.totalValue ?? 0), 0))) + .catch(() => setStockValue(null)) + }, [itemId]) + + const sortedLedger = useMemo( + () => (ledger ?? []).slice().sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), + [ledger] + ) + + /** Cumulative on-hand qty over time, summed across every warehouse — "total stock variation". */ + const stockTrend = useMemo(() => { + let running = 0 + const points: { date: string; qty: number }[] = [] + for (const row of sortedLedger) { + running += row.direction === "In" ? row.qtyBase : -row.qtyBase + points.push({ date: row.createdAt, qty: running }) + } + // Thin to at most ~40 points so the chart stays legible on a long history. + const step = Math.max(1, Math.ceil(points.length / 40)) + return points.filter((_, i) => i % step === 0 || i === points.length - 1) + }, [sortedLedger]) + + /** Qty in vs qty out per month, last 6 months with activity. */ + const monthlyActivity = useMemo(() => { + const byMonth = new Map() + for (const row of sortedLedger) { + const key = row.createdAt.slice(0, 7) // YYYY-MM + const bucket = byMonth.get(key) ?? { in: 0, out: 0 } + if (row.direction === "In") bucket.in += row.qtyBase + else bucket.out += row.qtyBase + byMonth.set(key, bucket) + } + return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6) + }, [sortedLedger]) + + // Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the + // blessed way to do it once (rather than on every render) is a one-time init function. + const [cutoff30] = useState(() => Date.now() - 30 * 24 * 60 * 60 * 1000) + const salesQty30d = useMemo( + () => + sortedLedger + .filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType) && new Date(r.createdAt).getTime() >= cutoff30) + .reduce((sum, r) => sum + r.qtyBase, 0), + [sortedLedger, cutoff30] + ) + const recentSales = useMemo( + () => sortedLedger.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType)).slice(-8).reverse(), + [sortedLedger] + ) + const recentGrns = useMemo( + () => sortedLedger.filter((r) => r.sourceDocType === "GRN").slice(-8).reverse(), + [sortedLedger] + ) + const currentStockQty = stockTrend.length > 0 ? stockTrend[stockTrend.length - 1].qty : 0 + + /** Opens the edit dialog fresh from the last-loaded item, discarding any unsaved + * edits left over from a previous cancelled session. */ + function openEdit() { + if (item) applyItem(item) + setErrors({}) + setSaveError(null) + setEditOpen(true) + } + + function closeEdit() { + if (item) applyItem(item) + setEditOpen(false) + } + async function handleSave() { if (!item || !etag) return setSaveError(null) @@ -152,6 +270,7 @@ export default function ItemDetailPage() { ) applyItem(result.data) setEtag(result.etag) + setEditOpen(false) toast.success("Item saved", `${result.data.sku} — ${result.data.name}`) } catch (err) { const code = (err as { code?: string })?.code @@ -159,6 +278,7 @@ export default function ItemDetailPage() { setConflict(true) setSaveError(errorMessage(err)) setSaving(false) + setEditOpen(false) return } const fe = fieldErrors(err) @@ -212,8 +332,19 @@ export default function ItemDetailPage() { return (
+ + + Back to Items + +
+
+ +

{item.sku}

@@ -228,9 +359,26 @@ export default function ItemDetailPage() {
- +
+ + +
+
+ +
+ + + + 0 ? `${sortedLedger.filter((r) => r.sourceDocType === "GRN").length}` : "0"} />
{conflict && ( @@ -249,8 +397,128 @@ export default function ItemDetailPage() {
{saveError}
)} -
-

Basic info

+
+
+
+ +

Total stock variation

+
+ {ledger === null ? ( + + ) : stockTrend.length > 1 ? ( + new Date(p.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }))} + datasets={[{ label: `On hand (${uomName(item.baseUomId)})`, data: stockTrend.map((p) => p.qty) }]} + /> + ) : ( +

No stock movements recorded yet.

+ )} +
+ +
+
+ +

Monthly purchases vs sales

+
+ {ledger === null ? ( + + ) : monthlyActivity.length > 0 ? ( + 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)" }, + ]} + /> + ) : ( +

No activity recorded yet.

+ )} +
+
+ +
+
+

Recent sales

+ {ledger === null ? ( + + ) : recentSales.length === 0 ? ( +

No sales recorded yet.

+ ) : ( + + + + Date + Document + Qty + Value + + + + {recentSales.map((row) => { + const href = docLink(row.sourceDocType, row.sourceDocId) + return ( + + {new Date(row.createdAt).toLocaleDateString()} + + {href ? ( + + {docLabel(row.sourceDocType, row.sourceDocId)} + + ) : ( + docLabel(row.sourceDocType, row.sourceDocId) + )} + + {row.qtyBase.toLocaleString()} + LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + ) + })} + +
+ )} +
+ +
+

Recent GRNs (purchases)

+ {ledger === null ? ( + + ) : recentGrns.length === 0 ? ( +

No goods received yet.

+ ) : ( + + + + Date + Document + Qty + Value + + + + {recentGrns.map((row) => ( + + {new Date(row.createdAt).toLocaleDateString()} + + + {docLabel(row.sourceDocType, row.sourceDocId)} + + + {row.qtyBase.toLocaleString()} + LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + ))} + +
+ )} +
+
+ + (open ? openEdit() : closeEdit())}> + + + Edit {item.sku} + Update this item's basic info, pricing and classification. +
@@ -465,16 +733,17 @@ export default function ItemDetailPage() {
-
- - -
-
+ + + + + +

{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03). diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index 5adbde6..c9670cd 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -1,20 +1,36 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" import Link from "next/link" -import { ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react" +import { + AlertTriangle, + ArrowDown, + ArrowUp, + ArrowUpDown, + Download, + ListTree, + Package, + Pencil, + Plus, + Ruler, + Search, +} from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" import { brandsApi } from "@/lib/api/brands" import { productConfig } from "@/lib/api/product-config" import { errorMessage } from "@/lib/error-map" +import { downloadCsv } from "@/lib/csv-export" import { cn } from "@/lib/utils" import { EntityStatus, PaginationMeta } from "@/types/common" -import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data" +import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data" import { Badge } from "@/components/ui/badge" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { ColumnsMenu } from "@/components/ui/columns-menu" import { Input } from "@/components/ui/input" import { PaginationFooter } from "@/components/ui/pagination-footer" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -22,14 +38,40 @@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" type StatusFilter = EntityStatus | "All" -type TrackingFilter = TrackingMode | "All" +type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate" +type SortOrder = "asc" | "desc" const PAGE_SIZE = 25 +const COLUMN_OPTIONS = [ + { key: "categoryBrand", label: "Category / Brand" }, + { key: "stockValue", label: "Stock Value" }, + { key: "lastPurchase", label: "Last Purchase" }, + { key: "sales30d", label: "Sales (30d)" }, + { key: "status", label: "Status" }, +] + +function formatMoney(n: number) { + return `LKR ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) { + return ( + + ) +} + export default function ItemsPage() { - const [items, setItems] = useState(null) + const router = useRouter() + const [items, setItems] = useState(null) const [categories, setCategories] = useState([]) - const [subCategories, setSubCategories] = useState([]) const [brands, setBrands] = useState([]) const [config, setConfig] = useState(null) const [pagination, setPagination] = useState(null) @@ -39,71 +81,132 @@ export default function ItemsPage() { const [query, setQuery] = useState("") const [status, setStatus] = useState("All") const [categoryId, setCategoryId] = useState("All") - const [trackingMode, setTrackingMode] = useState("All") const [page, setPage] = useState(1) + const [selected, setSelected] = useState>(new Set()) + const [sortKey, setSortKey] = useState("sku") + const [sortOrder, setSortOrder] = useState("asc") + const [visibleColumns, setVisibleColumns] = useState>({}) + useEffect(() => { const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) return () => clearTimeout(timeout) }, [searchInput]) - useEffect(() => setPage(1), [query, status, categoryId, trackingMode]) + useEffect(() => setPage(1), [query, status, categoryId]) function load() { setError(null) itemsApi - .list({ + .overview({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status, categoryId: categoryId === "All" ? undefined : categoryId, - trackingMode: trackingMode === "All" ? undefined : trackingMode, }) .then((res) => { setItems(res.items) setPagination(res.pagination) + setSelected(new Set()) }) .catch((err) => setError(errorMessage(err))) } - useEffect(load, [page, query, status, categoryId, trackingMode]) + useEffect(load, [page, query, status, categoryId]) useEffect(() => { categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) productConfig().then(setConfig).catch(() => {}) }, []) - // No "list all subcategories" endpoint exists — they're scoped per category — so once - // categories are in, fetch each one's subcategories in parallel to build a flat lookup. - useEffect(() => { - if (categories.length === 0) return - Promise.all( - categories.map((c) => - categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] })) - ) - ).then((results) => setSubCategories(results.flatMap((r) => r.items))) - }, [categories]) - function categoryName(id: number) { return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` } - function subCategoryName(id: number) { - return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}` - } - - function brandName(id: number) { + function brandName(id: number | null) { + if (id === null) return "—" return brands.find((b) => b.brandId === id)?.name ?? `#${id}` } - const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All" + const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" + + const showCol = (key: string) => visibleColumns[key] ?? true + function toggleColumn(key: string, next: boolean) { + setVisibleColumns((prev) => ({ ...prev, [key]: next })) + } + + function toggleSort(key: SortKey) { + if (key === sortKey) { + setSortOrder((o) => (o === "asc" ? "desc" : "asc")) + } else { + setSortKey(key) + setSortOrder("asc") + } + } + + const sortedItems = useMemo(() => { + if (!items) return null + return [...items].sort((a, b) => { + let cmp: number + switch (sortKey) { + case "stockValue": + cmp = a.stockValue - b.stockValue + break + case "salesQtyLast30Days": + cmp = a.salesQtyLast30Days - b.salesQtyLast30Days + break + case "lastPurchaseDate": + cmp = (a.lastPurchaseDate ? new Date(a.lastPurchaseDate).getTime() : 0) - + (b.lastPurchaseDate ? new Date(b.lastPurchaseDate).getTime() : 0) + break + case "name": + cmp = a.name.localeCompare(b.name) + break + default: + cmp = a.sku.localeCompare(b.sku) + } + return sortOrder === "asc" ? cmp : -cmp + }) + }, [items, sortKey, sortOrder]) + + const allSelected = !!sortedItems && sortedItems.length > 0 && selected.size === sortedItems.length + function toggleAll(checked: boolean) { + setSelected(checked && sortedItems ? new Set(sortedItems.map((i) => i.itemId)) : new Set()) + } + function toggleRow(itemId: number, checked: boolean) { + setSelected((prev) => { + const next = new Set(prev) + if (checked) next.add(itemId) + else next.delete(itemId) + return next + }) + } + + function handleExport() { + if (!sortedItems) return + const rows = selected.size > 0 ? sortedItems.filter((i) => selected.has(i.itemId)) : sortedItems + downloadCsv(`items-${new Date().toISOString().slice(0, 10)}`, rows, [ + { header: "SKU", value: (i) => i.sku }, + { header: "Name", value: (i) => i.name }, + { header: "Category", value: (i) => categoryName(i.categoryId) }, + { header: "Brand", value: (i) => brandName(i.brandId) }, + { header: "Status", value: (i) => i.status }, + { header: "Stock Qty", value: (i) => i.stockQty }, + { header: "Stock Value (LKR)", value: (i) => i.stockValue.toFixed(2) }, + { header: "Low Stock", value: (i) => (i.isLowStock ? "Yes" : "No") }, + { header: "Last Purchase Date", value: (i) => i.lastPurchaseDate ?? "" }, + { header: "Last Supplier", value: (i) => i.lastSupplierName ?? "" }, + { header: "Sales Qty (30d)", value: (i) => i.salesQtyLast30Days }, + ]) + } return (

Items

+

Stock value, last purchase and sales activity at a glance — click a row for its full profile.

@@ -149,19 +252,8 @@ export default function ItemsPage() { ))} - value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}> - - - - - All tracking modes - None - Batch - Serial - - value={status} onValueChange={(v) => setStatus(v ?? "All")}> - + @@ -170,13 +262,18 @@ export default function ItemsPage() { Inactive + +
{error && (
{error}
)} - {!error && items === null && ( + {!error && sortedItems === null && (
{Array.from({ length: 5 }).map((_, i) => ( @@ -184,7 +281,7 @@ export default function ItemsPage() {
)} - {!error && items !== null && items.length === 0 && ( + {!error && sortedItems !== null && sortedItems.length === 0 && (

{hasFilters ? "No items match your search/filter." : "No items yet."}

@@ -197,66 +294,111 @@ export default function ItemsPage() {
)} - {!error && items !== null && items.length > 0 && ( + {!error && sortedItems !== null && sortedItems.length > 0 && ( <> - - - - SKU - Name - Category - {config?.subcategoriesEnabled && Subcategory} - {config?.brandsEnabled && Brand} - Type - Status - Actions - - - - {items.map((item) => ( - - - - {item.sku} - - - {item.name} - {categoryName(item.categoryId)} - {config?.subcategoriesEnabled && ( - - {item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"} - - )} - {config?.brandsEnabled && ( - - {item.brandId !== null ? brandName(item.brandId) : "—"} - - )} - {item.stockNature} - - - {item.status} - - - - - - - +
+
+ + + + toggleAll(c === true)} aria-label="Select all" /> + + toggleSort("sku")} /> + toggleSort("name")} /> + {showCol("categoryBrand") && Category{config?.brandsEnabled ? " / Brand" : ""}} + {showCol("stockValue") && toggleSort("stockValue")} />} + {showCol("lastPurchase") && toggleSort("lastPurchaseDate")} />} + {showCol("sales30d") && toggleSort("salesQtyLast30Days")} />} + {showCol("status") && Status} + Actions - ))} - -
+ + + {sortedItems.map((item) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/products/${item.itemId}`) + }} + > + + toggleRow(item.itemId, c === true)} + aria-label={`Select ${item.sku}`} + /> + + +
+
+ +
+ {item.sku} +
+
+ {item.name} + {showCol("categoryBrand") && ( + +

{categoryName(item.categoryId)}

+ {config?.brandsEnabled &&

{brandName(item.brandId)}

} +
+ )} + {showCol("stockValue") && ( + +
+ {formatMoney(item.stockValue)} + {item.isLowStock && ( + + + Low + + )} +
+

{item.stockQty.toLocaleString()} on hand

+
+ )} + {showCol("lastPurchase") && ( + + {item.lastPurchaseDate ? ( + <> +

{new Date(item.lastPurchaseDate).toLocaleDateString()}

+

{item.lastSupplierName ?? "—"}

+ + ) : ( + "—" + )} +
+ )} + {showCol("sales30d") && {item.salesQtyLast30Days.toLocaleString()}} + {showCol("status") && ( + + + {item.status} + + + )} + + + + + +
+ ))} +
+ +
{pagination && } diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx index b04ff4a..2f0b1f8 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react" +import { useRouter } from "next/navigation" +import { ChevronLeft, ChevronRight, Clock, PackageSearch, Plus, Search } from "lucide-react" import { grnsApi } from "@/lib/api/grns" import { errorMessage } from "@/lib/error-map" @@ -33,6 +34,7 @@ type StatusFilter = GrnStatus | "All" const PAGE_SIZE = 25 export default function GrnListPage() { + const router = useRouter() const { activeBranchId, branchingEnabled } = useAuth() const stats = useDashboardStats() @@ -186,13 +188,19 @@ export default function GrnListPage() { Status Invoice Created - Actions {grns.map((grn) => { return ( - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/receiving/grn/${grn.grnId}`) + }} + > - + {grn.status !== "Confirmed" ? ( — ) : grn.purchaseInvoiceId ? ( @@ -219,21 +227,6 @@ export default function GrnListPage() { )} {new Date(grn.createdAt).toLocaleString()} - -
- - - - - {/* 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). */} -
-
) })} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index 54851a1..c22d004 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -202,7 +202,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i } async function save() { - if (!customerId || !warehouseId || !etag) return + if (!customerId || !warehouseId || !etag || !invoice) return if (lines.some((line) => !line.itemId || !line.warehouseId)) { setError("Select item, UOM and warehouse for every line.") return @@ -216,6 +216,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i { customerId, warehouseId, + branchId: invoice.branchId, invoiceType, lines: lines.map((line) => ({ itemId: Number(line.itemId), diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx index 63b0966..548e9b4 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx @@ -449,12 +449,14 @@ function NewSalesInvoiceContent() { async function submit() { if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.") + if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.") if (lines.some((line) => !line.itemId)) return setSubmitError("Select an item for every line.") if (lines.some((line) => !line.warehouseId)) return setSubmitError("Select a warehouse for every line.") const payload: CreateSalesInvoiceRequest = { customerId, warehouseId, + branchId: effectiveBranchId ?? 0, invoiceType, lines: lines.map((line) => ({ itemId: Number(line.itemId), diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx index 6cbfae2..b7c7d5b 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, FileText, Plus, Printer } from "lucide-react" +import { useRouter } from "next/navigation" +import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, FileText, Plus, Printer } from "lucide-react" import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" @@ -38,6 +39,7 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [ ] export default function SalesInvoicesPage() { + const router = useRouter() const { activeBranchId, branchingEnabled } = useAuth() const stats = useDashboardStats() @@ -240,7 +242,14 @@ export default function SalesInvoicesPage() { {visibleRows.map((row) => ( - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`) + }} + > {row.invoiceNo} @@ -261,7 +270,7 @@ export default function SalesInvoicesPage() { )}
- +
{row.status === "Draft" && (
diff --git a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx index bf51c12..219303e 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react" +import { useRouter } from "next/navigation" +import { ChevronLeft, ChevronRight, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react" import { rolesApi } from "@/lib/api/roles" import { errorMessage } from "@/lib/error-map" @@ -31,6 +32,7 @@ import { toast } from "@/components/ui/toast" const PAGE_SIZE = 10 export default function RolesPage() { + const router = useRouter() const [roles, setRoles] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -182,8 +184,15 @@ export default function RolesPage() { {roles.map((r) => ( - - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/settings/roles/${r.roleId}`) + }} + > + - +
- - - - (null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -202,8 +204,15 @@ export default function UsersPage() { {users.map((u) => ( - - + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/settings/users/${u.userId}`) + }} + > + - +
- - - +
+ +
+
+

{value}

+

{label}

+
+
+ ) +} export default function VendorDetailPage() { const params = useParams<{ id: string }>() - const router = useRouter() const vendorId = Number(params.id) const [vendor, setVendor] = useState(null) @@ -39,6 +58,10 @@ export default function VendorDetailPage() { const [saveError, setSaveError] = useState(null) const [saving, setSaving] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false) + const [editOpen, setEditOpen] = useState(false) + + // Profile activity: this vendor's recent goods receipts ("buying"). + const [grns, setGrns] = useState(null) // Every other vendor's code, to de-dupe against when the name edit regenerates this // vendor's own code (mirrors the create dialog's `allVendorCodes`, app/dashboard/vendors/page.tsx). @@ -56,18 +79,22 @@ export default function VendorDetailPage() { setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "") } + function applyVendor(data: Vendor) { + setVendor(data) + setCode(data.code) + setName(data.name) + setTerms(data.terms ?? "") + setTaxReg(data.taxReg ?? "") + setCurrency(data.currency) + } + function load() { setLoadError(null) vendorsApi .get(vendorId) .then(({ data, etag: tag }) => { - setVendor(data) + applyVendor(data) setEtag(tag) - setCode(data.code) - setName(data.name) - setTerms(data.terms ?? "") - setTaxReg(data.taxReg ?? "") - setCurrency(data.currency) setConflict(false) }) .catch((err) => setLoadError(errorMessage(err))) @@ -78,6 +105,39 @@ export default function VendorDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [vendorId]) + /** Opens the edit dialog fresh from the last-loaded vendor, discarding any unsaved + * edits left over from a previous cancelled session. */ + function openEdit() { + if (vendor) applyVendor(vendor) + setErrors({}) + setSaveError(null) + setEditOpen(true) + } + + function closeEdit() { + if (vendor) applyVendor(vendor) + setEditOpen(false) + } + + useEffect(() => { + if (!Number.isFinite(vendorId)) return + grnsApi.list({ vendorId, pageSize: 100 }).then((res) => setGrns(res.items)).catch(() => setGrns([])) + }, [vendorId]) + + const totalPurchased = useMemo(() => (grns ?? []).reduce((sum, g) => sum + g.totalValue, 0), [grns]) + const recentGrns = useMemo( + () => (grns ?? []).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 8), + [grns] + ) + const monthlyPurchases = useMemo(() => { + const byMonth = new Map() + for (const g of grns ?? []) { + const key = g.createdAt.slice(0, 7) + byMonth.set(key, (byMonth.get(key) ?? 0) + g.totalValue) + } + return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6) + }, [grns]) + async function handleSave() { setSaveError(null) const nextErrors: Record = {} @@ -87,11 +147,13 @@ export default function VendorDetailPage() { setErrors(nextErrors) if (Object.keys(nextErrors).length > 0 || !etag) return + if (!vendor) return setSaving(true) try { - const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency }, etag) + const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, etag) setVendor(result.data) setEtag(result.etag) + setEditOpen(false) toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`) } catch (err) { const code = (err as { code?: string })?.code @@ -99,6 +161,7 @@ export default function VendorDetailPage() { setConflict(true) setSaveError(errorMessage(err)) setSaving(false) + setEditOpen(false) return } const fe = fieldErrors(err) @@ -147,6 +210,14 @@ export default function VendorDetailPage() { return (
+ + + Back to Vendors + +
@@ -166,14 +237,30 @@ export default function VendorDetailPage() {
- +
+ + +
+
+ +
+ + +
{conflict && ( @@ -192,41 +279,98 @@ export default function VendorDetailPage() {
{saveError}
)} -
-
- - - +
+
+

Monthly purchases

+ {grns === null ? ( + + ) : monthlyPurchases.length > 0 ? ( + 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)" }]} + /> + ) : ( +

No purchases recorded yet.

+ )}
-
- - handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> - -
-
- - setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} /> -
-
- - setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} /> -
-
- - setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} /> - + +
+

Recent purchases (GRNs)

+ {grns === null ? ( + + ) : recentGrns.length === 0 ? ( +

No goods received from this vendor yet.

+ ) : ( + + + + Date + Document + Lines + Value + + + + {recentGrns.map((g) => ( + + {new Date(g.createdAt).toLocaleDateString()} + + + {g.docNo} + + + {g.lineCount} + LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + ))} + +
+ )}
-
- - -
+ (open ? openEdit() : closeEdit())}> + + + Edit {vendor.code} + Update this vendor's details. + +
+
+ + + +
+
+ + handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} /> +
+
+ + setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} /> + +
+
+ + + + +
+
) } diff --git a/Frontend/erp-system/app/dashboard/vendors/page.tsx b/Frontend/erp-system/app/dashboard/vendors/page.tsx index 00da382..a96b6af 100644 --- a/Frontend/erp-system/app/dashboard/vendors/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react" +import { useRouter } from "next/navigation" +import { Pencil, Plus, Search, Trash2, Truck } from "lucide-react" import { vendorsApi } from "@/lib/api/vendors" import { branchesHrmApi } from "@/lib/api/hrm-masters" @@ -21,7 +22,6 @@ import { Badge } from "@/components/ui/badge" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Dialog, - DialogClose, DialogContent, DialogDescription, DialogHeader, @@ -39,9 +39,11 @@ type StatusFilter = EntityStatus | "All" const PAGE_SIZE = 25 export default function VendorsPage() { - const { isAdmin, activeBranchId, branchingEnabled } = useAuth() + const router = useRouter() + const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth() const [vendors, setVendors] = useState(null) const [branches, setBranches] = useState([]) + const [branchFilterId, setBranchFilterId] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -104,11 +106,16 @@ export default function VendorsPage() { const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors + const lockedBranchId = isAdmin ? activeBranchId : myBranchId + const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId + const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null + function resetForm() { setName("") setTerms("") setTaxReg("") setCurrency("LKR") + setBranchFilterId(null) setErrors({}) } @@ -116,12 +123,13 @@ export default function VendorsPage() { const nextErrors: Record = {} if (!name.trim()) nextErrors.name = "Vendor name is required" if (!currency.trim()) nextErrors.currency = "Currency is required" + if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch" setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency }) + const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: effectiveBranchId ?? 0 }) toast.success("Vendor created", `${result.data.code} — ${result.data.name}`) setOpen(false) resetForm() @@ -198,6 +206,22 @@ export default function VendorsPage() { setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} /> + {showBranchPicker && ( + + Branch + value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}> + + + + + {branches.map((b) => ( + {b.name} + ))} + + + + + )}
diff --git a/Frontend/erp-system/app/globals.css b/Frontend/erp-system/app/globals.css index 778b5cf..da6daf6 100644 --- a/Frontend/erp-system/app/globals.css +++ b/Frontend/erp-system/app/globals.css @@ -7,7 +7,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); + --font-sans: var(--font-inter); --font-mono: var(--font-geist-mono); --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); diff --git a/Frontend/erp-system/app/layout.tsx b/Frontend/erp-system/app/layout.tsx index 28ad97b..6be9a6b 100644 --- a/Frontend/erp-system/app/layout.tsx +++ b/Frontend/erp-system/app/layout.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter, Geist_Mono } from "next/font/google"; import { Providers } from "@/components/providers"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], }); @@ -26,7 +26,7 @@ export default function RootLayout({ return ( diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index ebe75ed..0cf4b74 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -116,8 +116,6 @@ const navItems: { { title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart }, { title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX }, { title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck }, - { title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList }, - { title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText }, ], }, { @@ -129,6 +127,9 @@ const navItems: { chevron: true, children: [ { title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText }, + // Reuses the sales.invoices permission code — customers aren't yet their own + // seeded NavItem/Permission on the backend, so this rides along with Sales access. + { title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users }, { title: "Bundle Templates", code: "sales.bundle-templates", href: "/dashboard/sales/bundles/templates", icon: LayoutList }, { title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX }, { title: "Day End", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock }, diff --git a/Frontend/erp-system/components/ui/columns-menu.tsx b/Frontend/erp-system/components/ui/columns-menu.tsx new file mode 100644 index 0000000..9612dce --- /dev/null +++ b/Frontend/erp-system/components/ui/columns-menu.tsx @@ -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 + onToggle: (key: string, next: boolean) => void +}) { + return ( + + }> + + Columns + + + Show columns + + {columns.map((col) => ( + !col.required && onToggle(col.key, checked === true)} + disabled={col.required} + > + {col.label} + + ))} + + + ) +} diff --git a/Frontend/erp-system/lib/api/customers.ts b/Frontend/erp-system/lib/api/customers.ts index eaeabb9..3164229 100644 --- a/Frontend/erp-system/lib/api/customers.ts +++ b/Frontend/erp-system/lib/api/customers.ts @@ -1,15 +1,35 @@ -import { apiRequest, buildQuery } from "@/lib/api-client" -import { PagedResponse } from "@/types/common" -import { Customer } from "@/types/customers" +// One typed client method per customer endpoint. ETag/If-Match on update, PATCH status +// for deactivate (masters are deactivated, not hard-deleted, FR-MD-08). +import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +import { ApiResult, EntityStatus, PagedResponse } from "@/types/common" +import { CreateCustomerRequest, Customer, CustomerType, UpdateCustomerRequest } from "@/types/customers" export interface ListCustomersParams { page?: number pageSize?: number q?: string + status?: EntityStatus + customerType?: CustomerType } export const customersApi = { list(params: ListCustomersParams = {}): Promise> { return apiRequest>(`/customers${buildQuery(params)}`) }, + + get(customerId: number): Promise> { + return apiRequestWithETag(`/customers/${customerId}`) + }, + + create(request: CreateCustomerRequest): Promise> { + return apiRequestWithETag("/customers", { method: "POST", body: request }) + }, + + update(customerId: number, request: UpdateCustomerRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/customers/${customerId}`, { method: "PUT", body: request, ifMatch }) + }, + + updateStatus(customerId: number, status: EntityStatus): Promise { + return apiRequest(`/customers/${customerId}/status`, { method: "PATCH", body: { status } }) + }, } diff --git a/Frontend/erp-system/lib/api/items.ts b/Frontend/erp-system/lib/api/items.ts index ebbd673..ba065c9 100644 --- a/Frontend/erp-system/lib/api/items.ts +++ b/Frontend/erp-system/lib/api/items.ts @@ -6,6 +6,7 @@ import { CreateItemRequest, Item, ItemListItem, + ItemOverview, ItemReorderSetting, ItemScanResult, TrackingMode, @@ -25,11 +26,27 @@ export interface ListItemsParams { sort?: string } +export interface ListItemOverviewParams { + page?: number + pageSize?: number + q?: string + status?: EntityStatus + categoryId?: number + subCategoryId?: number + brandId?: number +} + export const itemsApi = { list(params: ListItemsParams = {}): Promise> { return apiRequest>(`/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> { + return apiRequest>(`/items/overview${buildQuery(params)}`) + }, + get(itemId: number): Promise> { return apiRequestWithETag(`/items/${itemId}`) }, diff --git a/Frontend/erp-system/lib/csv-export.ts b/Frontend/erp-system/lib/csv-export.ts new file mode 100644 index 0000000..c3b33c6 --- /dev/null +++ b/Frontend/erp-system/lib/csv-export.ts @@ -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 { + 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(rows: T[], columns: CsvColumn[]): 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(filename: string, rows: T[], columns: CsvColumn[]): 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) +} diff --git a/Frontend/erp-system/lib/customer-code.ts b/Frontend/erp-system/lib/customer-code.ts new file mode 100644 index 0000000..9145956 --- /dev/null +++ b/Frontend/erp-system/lib/customer-code.ts @@ -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}` +} diff --git a/Frontend/erp-system/next.config.ts b/Frontend/erp-system/next.config.ts index 5df0395..2dbe359 100644 --- a/Frontend/erp-system/next.config.ts +++ b/Frontend/erp-system/next.config.ts @@ -8,7 +8,11 @@ import path from "path" // // Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that // this proxy would reject. -const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:6701" +// +// Uses 127.0.0.1, not "localhost": Node resolves "localhost" to the IPv6 loopback (::1) +// first on this machine, but Kestrel only binds the IPv4 wildcard (0.0.0.0), so every +// proxied request failed with ECONNREFUSED ::1:6701 until this was pinned to IPv4. +const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://127.0.0.1:6701" const nextConfig: NextConfig = { turbopack: { diff --git a/Frontend/erp-system/tailwind.config.ts b/Frontend/erp-system/tailwind.config.ts index 286c217..0cb837e 100644 --- a/Frontend/erp-system/tailwind.config.ts +++ b/Frontend/erp-system/tailwind.config.ts @@ -29,7 +29,7 @@ const config: Config = { info: "oklch(0.631 0.215 259.262)", }, fontFamily: { - sans: "var(--font-geist-sans)", + sans: "var(--font-inter)", mono: "var(--font-geist-mono)", }, borderRadius: { diff --git a/Frontend/erp-system/types/customers.ts b/Frontend/erp-system/types/customers.ts index 7217692..c16f97e 100644 --- a/Frontend/erp-system/types/customers.ts +++ b/Frontend/erp-system/types/customers.ts @@ -24,3 +24,23 @@ export interface Customer { createdAt: string updatedAt: string | null } + +export interface CreateCustomerRequest { + customerCode: string + customerType: CustomerType + name: string + displayName?: string | null + phone?: string | null + email?: string | null + addressLine1?: string | null + addressLine2?: string | null + city?: string | null + country?: string | null + taxRegistrationNo?: string | null + creditLimit: number + creditDays: number + defaultWarehouseId?: number | null + branchId: number +} + +export type UpdateCustomerRequest = CreateCustomerRequest diff --git a/Frontend/erp-system/types/grn.ts b/Frontend/erp-system/types/grn.ts index 5773b02..5cd613f 100644 --- a/Frontend/erp-system/types/grn.ts +++ b/Frontend/erp-system/types/grn.ts @@ -125,6 +125,8 @@ export interface GrnSummary { createdAt: string postedAt: string | null lineCount: number + /** Sum of every line's `lineTotal` (after discount, incl. VAT) — payable to the vendor. */ + totalValue: number purchaseInvoiceId: number | null purchaseInvoiceNo: string | null } diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 4e1d98b..153b61d 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -59,6 +59,28 @@ export interface ItemListItem extends ItemContent { status: EntityStatus } +/** + * Item row enriched with cross-module aggregates for the Items overview table + * (`GET /items/overview`) — current FIFO stock value, low-stock flag, last purchase + * (date + supplier), and quantity sold in the trailing 30 days across every sale channel. + */ +export interface ItemOverview { + itemId: number + sku: string + name: string + categoryId: number + subCategoryId: number | null + brandId: number | null + status: EntityStatus + stockQty: number + stockValue: number + isLowStock: boolean + lastPurchaseDate: string | null + lastSupplierId: number | null + lastSupplierName: string | null + salesQtyLast30Days: number +} + export interface ItemReorderSetting { warehouseId: number reorderPoint: number @@ -196,6 +218,9 @@ export interface CreateVendorRequest { terms?: string | null taxReg?: string | null currency: string + /** Required when branching is enabled — resolved client-side from the logged-in user's + * own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */ + branchId?: number } export type UpdateVendorRequest = CreateVendorRequest diff --git a/Frontend/erp-system/types/procurement.ts b/Frontend/erp-system/types/procurement.ts index d77958b..2746429 100644 --- a/Frontend/erp-system/types/procurement.ts +++ b/Frontend/erp-system/types/procurement.ts @@ -42,6 +42,9 @@ export interface CreateReqLineInput { export interface CreateRequisitionRequest { lines: CreateReqLineInput[] + /** Required when branching is enabled — resolved client-side from the logged-in user's + * own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */ + branchId?: number } // --- 3.2 RFQs & Quotations ---------------------------------------------------------- @@ -202,6 +205,9 @@ export interface CreatePurchaseOrderRequest { lines: CreatePoLineInput[] /** When true the PO is created as an editable/deletable Draft; false (default) issues it straight into PendingApproval. */ saveAsDraft?: boolean + /** Required when branching is enabled — resolved client-side from the logged-in user's + * own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */ + branchId?: number } /** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */ diff --git a/Frontend/erp-system/types/sales.ts b/Frontend/erp-system/types/sales.ts index f06ec94..ffb8c38 100644 --- a/Frontend/erp-system/types/sales.ts +++ b/Frontend/erp-system/types/sales.ts @@ -45,6 +45,7 @@ export interface SalesInvoiceSummary { customerId: number customerSnapshotName: string warehouseId: number + branchId: number invoiceType: SalesInvoiceType status: SalesInvoiceStatus totals: SalesInvoiceTotals @@ -137,6 +138,9 @@ export interface CreateSalesInvoiceLineRequest { export interface CreateSalesInvoiceRequest { customerId: number warehouseId: number + /** Required — resolved client-side from the logged-in user's own branch, or picked by + * an Admin viewing "All Branches" (see `useAuth()`). */ + branchId: number invoiceType: SalesInvoiceType lines: CreateSalesInvoiceLineRequest[] }