+
{item.sku}
@@ -228,9 +359,26 @@ export default function ItemDetailPage() {
-
- {togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
-
+
+
+
+ Edit
+
+
+ {togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
+
+
+
+
+
+
+
+
+ 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.
+
SKU
@@ -465,16 +733,17 @@ export default function ItemDetailPage() {
-
- router.push("/dashboard/products")}>
- Back
-
-
-
- {saving ? "Saving…" : "Save changes"}
-
-
-
+
+
+ Cancel
+
+
+
+ {saving ? "Saving…" : "Save changes"}
+
+
+
+
{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 (
+
+ {label}
+ {active ? (
+ order === "asc" ? :
+ ) : (
+
+ )}
+
+ )
+}
+
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
+
+
+
+ Export{selected.size > 0 ? ` (${selected.size})` : ""}
+
{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.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}`)
+ }}
+ >
+
-
+
+ )
+}
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() {
-
- {togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
-
+
+
+
+ Edit
+
+
+ {togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
+
+
+
+
+
+
+
+
{conflict && (
@@ -192,41 +279,98 @@ export default function VendorDetailPage() {
{saveError}
)}
-
-
-
Code (auto-generated)
-
-
+
+
+
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.
+ )}
-
- Name
- handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
-
-
-
- Payment terms
- setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
-
-
- Tax registration
- setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
-
-
-
Currency
-
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 })}
+
+ ))}
+
+
+ )}
-
- router.push("/dashboard/vendors")}>
- Cancel
-
-
-
- {saving ? "Saving…" : "Save changes"}
-
-
+
(open ? openEdit() : closeEdit())}>
+
+
+ Edit {vendor.code}
+ Update this vendor's details.
+
+
+
+
+ Cancel
+
+
+
+ {saving ? "Saving…" : "Save changes"}
+
+
+
+
)
}
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}
+ ))}
+
+
+
+
+ )}
setOpen(false)} disabled={submitting}>
@@ -271,7 +295,14 @@ export default function VendorsPage() {
{visibleVendors.map((v) => (
-
+ {
+ if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
+ router.push(`/dashboard/vendors/${v.vendorId}`)
+ }}
+ >
{v.code}
{v.name}
{v.terms ?? "—"}
@@ -292,55 +323,8 @@ export default function VendorsPage() {
{v.status}
-
+
-
- }
- >
-
-
-
-
- {v.code}
- {v.name}
-
-
-
- Payment terms
- {v.terms ?? "—"}
-
-
- Tax registration
- {v.taxReg ?? "—"}
-
-
- Currency
- {v.currency}
-
-
- Status
-
- {v.status}
-
-
-
- Created
- {new Date(v.createdAt).toLocaleString()}
-
-
-
- }>Close
-
-
-
-
(null)
@@ -345,8 +346,15 @@ export default function WarehousesPage() {
{warehouses.map((w) => (
-
-
+ {
+ if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
+ router.push(`/dashboard/warehouse/${w.warehouseId}`)
+ }}
+ >
+
{binCount(w.warehouseId)}
-
+
-
-
-
openEdit(w)} aria-label={`Edit ${w.code}`} title={`Edit ${w.code}`}>
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[]
}