From f5e8e0e5b9e14886977d55f3a37adac5848d2999 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Wed, 23 Sep 2026 16:55:46 +0530 Subject: [PATCH 1/2] responsiveness and ui fixers --- .../app/dashboard/customers/[id]/page.tsx | 181 +++++-- .../app/dashboard/customers/page.tsx | 318 ++++++++---- .../erp-system/app/dashboard/help/page.tsx | 134 +++-- .../app/dashboard/help/tickets/[id]/page.tsx | 8 +- .../dashboard/hrm/attendance/[id]/page.tsx | 130 +++-- .../app/dashboard/hrm/attendance/page.tsx | 116 ++++- .../app/dashboard/hrm/employees/[id]/page.tsx | 302 +++++++---- .../app/dashboard/hrm/employees/new/page.tsx | 6 +- .../app/dashboard/hrm/employees/page.tsx | 311 ++++++++---- .../app/dashboard/hrm/leave/page.tsx | 214 +++++--- .../hrm/payroll/[id]/lines/[lineId]/page.tsx | 4 +- .../app/dashboard/hrm/payroll/[id]/page.tsx | 218 +++++--- .../app/dashboard/hrm/payroll/page.tsx | 129 +++-- .../hrm/settings/departments/page.tsx | 291 +++++++---- .../hrm/settings/document-types/page.tsx | 287 +++++++---- .../hrm/settings/leave-types/page.tsx | 295 +++++++---- .../hrm/settings/salary-components/page.tsx | 287 +++++++---- .../dashboard/hrm/settings/statutory/page.tsx | 146 ++++-- .../hrm/settings/work-shifts/page.tsx | 291 +++++++---- .../dashboard/ledgers/balance-sheet/page.tsx | 2 +- .../dashboard/ledgers/general-ledger/page.tsx | 64 +-- .../ledgers/profit-and-loss/page.tsx | 2 +- .../app/dashboard/ledgers/tax-report/page.tsx | 2 +- .../dashboard/ledgers/trial-balance/page.tsx | 58 +-- Frontend/erp-system/app/dashboard/page.tsx | 139 +++-- .../purchase-invoices/[id]/page.tsx | 6 +- .../purchase-invoices/new/page.tsx | 6 +- .../procurement/purchase-invoices/page.tsx | 132 ++++- .../procurement/purchase-orders/[id]/page.tsx | 170 ++++++- .../procurement/purchase-orders/new/page.tsx | 203 ++++++-- .../procurement/purchase-orders/page.tsx | 228 +++++++-- .../procurement/purchase-returns/new/page.tsx | 140 ++++-- .../procurement/purchase-returns/page.tsx | 203 ++++++-- .../app/dashboard/products/[id]/page.tsx | 197 ++++++-- .../app/dashboard/products/brands/page.tsx | 10 +- .../products/categories/[id]/page.tsx | 176 +++++-- .../dashboard/products/categories/page.tsx | 270 ++++++---- .../app/dashboard/products/page.tsx | 148 +++++- .../app/dashboard/products/settings/page.tsx | 2 +- .../app/dashboard/receiving/grn/[id]/page.tsx | 117 ++++- .../app/dashboard/receiving/grn/new/page.tsx | 194 ++++++- .../app/dashboard/receiving/grn/page.tsx | 259 +++++++--- .../sales/bundles/templates/page.tsx | 438 ++++++++++------ .../app/dashboard/sales/day-end/page.tsx | 63 ++- .../dashboard/sales/free-issues/new/page.tsx | 476 +++++++++++------- .../app/dashboard/sales/invoices/new/page.tsx | 193 ++++++- .../app/dashboard/sales/invoices/page.tsx | 137 ++++- .../sales/sales-returns/new/page.tsx | 283 +---------- .../dashboard/sales/sales-returns/page.tsx | 213 ++++++-- .../settings/branch-configuration/page.tsx | 2 +- .../settings/company-profile/page.tsx | 6 +- .../settings/invoice-template/page.tsx | 46 +- .../dashboard/settings/roles/[id]/page.tsx | 38 +- .../app/dashboard/settings/roles/new/page.tsx | 10 +- .../app/dashboard/settings/roles/page.tsx | 297 +++++++---- .../dashboard/settings/users/[id]/page.tsx | 12 +- .../app/dashboard/settings/users/page.tsx | 220 +++++--- .../dashboard/stock/adjustments/new/page.tsx | 120 +++-- .../app/dashboard/stock/adjustments/page.tsx | 201 ++++++-- .../app/dashboard/stock/counts/[id]/page.tsx | 109 ++-- .../app/dashboard/stock/counts/new/page.tsx | 6 +- .../app/dashboard/stock/counts/page.tsx | 230 +++++++-- .../app/dashboard/stock/enquiry/page.tsx | 193 +++++-- .../app/dashboard/stock/ledger/page.tsx | 206 ++++++-- .../dashboard/stock/reorder-alerts/page.tsx | 132 +++-- .../dashboard/stock/transfers/[id]/page.tsx | 86 +++- .../dashboard/stock/transfers/new/page.tsx | 180 +++++-- .../app/dashboard/stock/transfers/page.tsx | 230 +++++++-- .../app/dashboard/stock/valuation/page.tsx | 141 ++++-- .../app/dashboard/stock/wastage/new/page.tsx | 6 +- .../app/dashboard/stock/wastage/page.tsx | 163 ++++-- .../app/dashboard/vendors/[id]/page.tsx | 181 +++++-- .../erp-system/app/dashboard/vendors/page.tsx | 314 ++++++++---- .../app/dashboard/warehouse/[id]/page.tsx | 167 ++++-- .../app/dashboard/warehouse/page.tsx | 255 +++++++--- .../erp-system/components/Layouts/Header.tsx | 90 +++- .../components/auth/RolePermissionActions.tsx | 6 +- .../components/auth/RolePermissionTree.tsx | 2 +- .../components/hrm/CodeNameMasterPage.tsx | 285 +++++++---- .../components/sales/SalesReturnForm.tsx | 352 +++++++++++++ .../components/settings/BranchDetailPage.tsx | 29 +- .../components/settings/BranchMasterPage.tsx | 313 ++++++++---- .../erp-system/components/ui/bar-chart.tsx | 40 +- Frontend/erp-system/components/ui/kpi-row.tsx | 2 +- .../components/ui/pagination-footer.tsx | 100 ++-- .../erp-system/components/ui/stat-card.tsx | 1 - Frontend/erp-system/components/ui/table.tsx | 8 +- Frontend/erp-system/types/stock.ts | 6 + 88 files changed, 9408 insertions(+), 3576 deletions(-) create mode 100644 Frontend/erp-system/components/sales/SalesReturnForm.tsx diff --git a/Frontend/erp-system/app/dashboard/customers/[id]/page.tsx b/Frontend/erp-system/app/dashboard/customers/[id]/page.tsx index f58ef1b..a7abbee 100644 --- a/Frontend/erp-system/app/dashboard/customers/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/customers/[id]/page.tsx @@ -18,12 +18,15 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" +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" import BarChart from "@/components/ui/bar-chart" +const INVOICE_PAGE_SIZE_OPTIONS = [5, 10, 15, 20] + /** 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 ( @@ -127,18 +130,40 @@ export default function CustomerDetailPage() { 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 ?? []).slice().sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()), [invoices] ) + + const [invoicePage, setInvoicePage] = useState(1) + const [invoicePageSize, setInvoicePageSize] = useState(INVOICE_PAGE_SIZE_OPTIONS[0]) + const [mobileInvoiceCount, setMobileInvoiceCount] = useState(INVOICE_PAGE_SIZE_OPTIONS[0]) + const invoicePagination = { + page: invoicePage, + pageSize: invoicePageSize, + totalItems: recentInvoices.length, + totalPages: Math.max(1, Math.ceil(recentInvoices.length / invoicePageSize)), + } + const pagedInvoices = recentInvoices.slice((invoicePage - 1) * invoicePageSize, invoicePage * invoicePageSize) 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) + return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5) }, [invoices]) + // Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the + // narrow card instead of leaving blank trailing slots — there's less width to spare there. + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const mql = window.matchMedia("(max-width: 639px)") + setIsMobile(mql.matches) + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, []) + async function handleSave() { if (!customer || !etag) return setSaveError(null) @@ -260,14 +285,15 @@ export default function CustomerDetailPage() { -
-
-
+

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)" }]} + // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of + // activity, Chart.js would otherwise center the lone category across the full + // width. Trailing blank slots keep the axis/gridlines spanning the full card + // while the real bars sit at the start. Skipped on mobile — there the bars + // should stretch to fill the narrower card instead. + labels={[ + ...monthlySales.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })), + ...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill("")), + ]} + datasets={[ + { + label: "Sold (LKR)", + data: [ + ...monthlySales.map(([, v]) => v), + ...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill(null)), + ], + backgroundColor: "rgba(239,68,68,0.6)", + }, + ]} + barPercentage={0.6} + categoryPercentage={0.6} + height={340} /> ) : (

No sales recorded yet.

@@ -320,34 +366,105 @@ export default function CustomerDetailPage() {

Recent sales invoices

{invoices === null ? ( - +
+
+ + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
) : 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 })} - + <> + {/* Mobile: stacked cards, "Show more" instead of pagination. */} +
+ {recentInvoices.slice(0, mobileInvoiceCount).map((inv) => ( + +
+ {inv.invoiceNo} + {new Date(inv.invoiceDate).toLocaleDateString()} +
+
+ + {inv.status} + + LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+ ))} - -
+ + {mobileInvoiceCount < recentInvoices.length && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Date + Invoice + Status + Total + + + + {pagedInvoices.map((inv) => ( + + {new Date(inv.invoiceDate).toLocaleDateString()} + + + {inv.invoiceNo} + + + + + {inv.status} + + + + LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + + ))} + +
+
+
+ +
+ { setInvoicePage(1); setInvoicePageSize(size) }} + /> +
+ )}
diff --git a/Frontend/erp-system/app/dashboard/customers/page.tsx b/Frontend/erp-system/app/dashboard/customers/page.tsx index a057a5d..42ac750 100644 --- a/Frontend/erp-system/app/dashboard/customers/page.tsx +++ b/Frontend/erp-system/app/dashboard/customers/page.tsx @@ -3,7 +3,7 @@ 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 { Eye, Pencil, Plus, Search, Trash2, Users } from "lucide-react" import { customersApi } from "@/lib/api/customers" import { branchesHrmApi } from "@/lib/api/hrm-masters" @@ -37,7 +37,10 @@ import { toast } from "@/components/ui/toast" type StatusFilter = EntityStatus | "All" type TypeFilter = CustomerType | "All" -const PAGE_SIZE = 25 +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function CustomersPage() { const router = useRouter() @@ -53,14 +56,15 @@ export default function CustomersPage() { const [status, setStatus] = useState("All") const [customerType, setCustomerType] = useState("All") const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[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 [creditLimit, setCreditLimit] = useState("") + const [creditDays, setCreditDays] = useState("") const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) @@ -68,6 +72,7 @@ export default function CustomersPage() { // customer's own code (mirrors the vendor create dialog). const [allCustomerCodes, setAllCustomerCodes] = useState([]) const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : "" @@ -86,16 +91,22 @@ export default function CustomersPage() { status: status === "All" ? undefined : status, customerType: customerType === "All" ? undefined : customerType, page, - pageSize: PAGE_SIZE, + pageSize, }) .then((res) => { setCustomers(res.items) setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) } - useEffect(load, [query, status, customerType, page]) + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(load, [query, status, customerType, page, pageSize]) useEffect(() => { customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {}) @@ -118,8 +129,8 @@ export default function CustomersPage() { setNewCustomerType("B2C") setPhone("") setEmail("") - setCreditLimit("0") - setCreditDays("0") + setCreditLimit("") + setCreditDays("") setBranchFilterId(null) setErrors({}) } @@ -229,11 +240,11 @@ export default function CustomersPage() {
Credit limit (LKR) - setCreditLimit(e.target.value)} /> + setCreditLimit(e.target.value)} /> Credit days - setCreditDays(e.target.value)} /> + setCreditDays(e.target.value)} />
{showBranchPicker && ( @@ -303,9 +314,21 @@ export default function CustomersPage() { )} {!error && customers === null && ( -
+
+
+ + +
{Array.from({ length: 4 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -321,86 +344,203 @@ export default function CustomersPage() { {!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} - - - -
- - - + {/* Mobile: stacked cards. */} +
+ {customers.map((c) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/customers/${c.customerId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+

{c.customerCode}

+

{c.displayName ?? c.name}

+
+ + {c.status} + +
- - - } - > - - - handleDeactivate(c)} +
+
+

Type

+ {c.customerType} +
+
+

Phone

+

{c.phone ?? "—"}

+
+
+

Credit limit

+

LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}

+
+
+ +
+ + + + + + - -
- - - ))} - -
+ } + > + + + handleDeactivate(c)} + /> + +
+ + ))} - {pagination && } + {pagination && customers.length < pagination.totalItems && ( + + )} + + + {/* Desktop: table. */} +
+
+ + + + 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 && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} )} diff --git a/Frontend/erp-system/app/dashboard/help/page.tsx b/Frontend/erp-system/app/dashboard/help/page.tsx index d47b2cc..6c69834 100644 --- a/Frontend/erp-system/app/dashboard/help/page.tsx +++ b/Frontend/erp-system/app/dashboard/help/page.tsx @@ -39,6 +39,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] +// Mobile has no page-size selector — tapping "Show more" just grows the page size +// client-side, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 const statusStyles: Record = { Open: "border-transparent bg-primary/10 text-primary", @@ -288,53 +291,98 @@ export default function HelpPage() { )} {!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && ( -
-
- - - - Subject - {isAdmin && Submitted By} - Status - Messages - Last Updated - - - - {pageItems.map((t) => ( - - - - {t.subject} - - - {isAdmin && {t.createdByName}} - - - {statusLabels[t.status]} - - - {t.messageCount} - - {new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - })} - - - ))} - -
+ <> + {/* Mobile: stacked cards. */} +
+ {pageItems.map((t) => ( + +
+

{t.subject}

+ + {statusLabels[t.status]} + +
+
+ {isAdmin && ( +
+

Submitted By

+

{t.createdByName}

+
+ )} +
+

Messages

+

{t.messageCount}

+
+
+

Last Updated

+

+ {new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} +

+
+
+ + ))} + + {totalItems > pageItems.length && ( + + )}
-
+ + {/* Desktop: table. */} +
+
+ + + + Subject + {isAdmin && Submitted By} + Status + Messages + Last Updated + + + + {pageItems.map((t) => ( + + + + {t.subject} + + + {isAdmin && {t.createdByName}} + + + {statusLabels[t.status]} + + + {t.messageCount} + + {new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + + ))} + +
+
+
+ )} {totalItems > 0 && ( -
+

Showing {(page - 1) * pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/help/tickets/[id]/page.tsx b/Frontend/erp-system/app/dashboard/help/tickets/[id]/page.tsx index 39f5313..c45b5fc 100644 --- a/Frontend/erp-system/app/dashboard/help/tickets/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/help/tickets/[id]/page.tsx @@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() { {isAdmin ? ( -

- - +
@@ -356,104 +366,203 @@ export default function DepartmentsPage() { )} {!error && items !== null && items.length > 0 && ( -
-
- - - - Code - Name - Parent - Status - Created - Actions - - - - {items.map((d) => ( - - {d.code} - {d.name} - + <> + {/* Mobile: stacked cards. */} +
+ {items.map((d) => ( +
+
+
+

{d.name}

+ {d.code} +
+ + {d.status} + +
+ +
+
+

Parent

+

{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"} - - - - {d.status} - - - +

+
+
+

Created

+

{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} - - -

+

+
+
+ +
+ + + + openEdit(d)} - > - - - - - - } - > - - - handleDelete(d)} - /> - -
- - - ))} - -
+ /> + } + > + + + handleDelete(d)} + /> + +
+
+ ))} + + {pagination && items.length < pagination.totalItems && ( + + )}
-
+ + {/* Desktop: table. */} +
+
+ + + + Code + Name + Parent + Status + Created + Actions + + + + {items.map((d) => ( + + {d.code} + {d.name} + + {items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"} + + + + {d.status} + + + + {new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} + + +
+ + + + + } + > + + + handleDelete(d)} + /> + +
+
+
+ ))} +
+
+
+
+ )} {pagination && pagination.totalItems > 0 && ( -
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx index 448b672..2380de8 100644 --- a/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx @@ -24,6 +24,9 @@ import { toast } from "@/components/ui/toast" const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function DocumentTypesPage() { const [items, setItems] = useState(null) @@ -34,6 +37,7 @@ export default function DocumentTypesPage() { const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) const [open, setOpen] = useState(false) const [code, setCode] = useState("") @@ -59,6 +63,12 @@ export default function DocumentTypesPage() { .list({ page, pageSize, q: debouncedSearch || undefined }) .then((res) => { setItems(res.items); setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps @@ -261,9 +271,9 @@ export default function DocumentTypesPage() { Track expiry date (e.g. passport, visa) -

- - +
@@ -332,102 +342,199 @@ export default function DocumentTypesPage() { )} {!error && items !== null && items.length > 0 && ( -
-
- - - - Code - Name - Category - Status - Created - Actions - - - - {items.map((t) => ( - - {t.code} - {t.name} - {t.category} - - - {t.status} - - - + <> + {/* Mobile: stacked cards. */} +
+ {items.map((t) => ( +
+
+
+

{t.name}

+ {t.code} +
+ + {t.status} + +
+ +
+
+

Category

+

{t.category}

+
+
+

Created

+

{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} - - -

+

+
+
+ +
+ + + + openEdit(t)} - > - - - - - - } - > - - - handleDelete(t)} - /> - -
- - - ))} - -
+ /> + } + > + + + handleDelete(t)} + /> + +
+
+ ))} + + {pagination && items.length < pagination.totalItems && ( + + )}
-
+ + {/* Desktop: table. */} +
+
+ + + + Code + Name + Category + Status + Created + Actions + + + + {items.map((t) => ( + + {t.code} + {t.name} + {t.category} + + + {t.status} + + + + {new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} + + +
+ + + + + } + > + + + handleDelete(t)} + /> + +
+
+
+ ))} +
+
+
+
+ )} {pagination && pagination.totalItems > 0 && ( -
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx index 23a530a..50d1240 100644 --- a/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx @@ -22,6 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function LeaveTypesPage() { const [items, setItems] = useState(null) @@ -32,6 +35,7 @@ export default function LeaveTypesPage() { const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) const [open, setOpen] = useState(false) const [code, setCode] = useState("") @@ -60,6 +64,12 @@ export default function LeaveTypesPage() { .list({ page, pageSize, q: debouncedSearch || undefined }) .then((res) => { setItems(res.items); setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps @@ -279,9 +289,9 @@ export default function LeaveTypesPage() { Requires approval -

- - +
@@ -351,104 +361,205 @@ export default function LeaveTypesPage() { )} {!error && items !== null && items.length > 0 && ( -
-
- - - - Code - Name - Days/yr - Paid - Status - Created - Actions - - - - {items.map((t) => ( - - {t.code} - {t.name} - {t.accrualPerYear} - {t.isPaid ? "Yes" : "No"} - - - {t.status} - - - + <> + {/* Mobile: stacked cards. */} +
+ {items.map((t) => ( +
+
+
+

{t.name}

+ {t.code} +
+ + {t.status} + +
+ +
+
+

Days/yr

+

{t.accrualPerYear}

+
+
+

Paid

+

{t.isPaid ? "Yes" : "No"}

+
+
+

Created

+

{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} - - -

+

+
+
+ +
+ + + + openEdit(t)} - > - - - - - - } - > - - - handleDelete(t)} - /> - -
- - - ))} - -
+ /> + } + > + + + handleDelete(t)} + /> + +
+
+ ))} + + {pagination && items.length < pagination.totalItems && ( + + )}
-
+ + {/* Desktop: table. */} +
+
+ + + + Code + Name + Days/yr + Paid + Status + Created + Actions + + + + {items.map((t) => ( + + {t.code} + {t.name} + {t.accrualPerYear} + {t.isPaid ? "Yes" : "No"} + + + {t.status} + + + + {new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} + + +
+ + + + + } + > + + + handleDelete(t)} + /> + +
+
+
+ ))} +
+
+
+
+ )} {pagination && pagination.totalItems > 0 && ( -
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx index 795299e..34a47cf 100644 --- a/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx @@ -23,6 +23,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function SalaryComponentsPage() { const [items, setItems] = useState(null) @@ -33,6 +36,7 @@ export default function SalaryComponentsPage() { const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) const [open, setOpen] = useState(false) const [code, setCode] = useState("") @@ -57,6 +61,12 @@ export default function SalaryComponentsPage() { .list({ page, pageSize, q: debouncedSearch || undefined }) .then((res) => { setItems(res.items); setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps @@ -254,9 +264,9 @@ export default function SalaryComponentsPage() { EPF/ETF applicable -

- - +
@@ -325,102 +335,199 @@ export default function SalaryComponentsPage() { )} {!error && items !== null && items.length > 0 && ( -
-
- - - - Code - Name - Type - Status - Created - Actions - - - - {items.map((c) => ( - - {c.code} - {c.name} - {c.componentType} - - - {c.status} - - - + <> + {/* Mobile: stacked cards. */} +
+ {items.map((c) => ( +
+
+
+

{c.name}

+ {c.code} +
+ + {c.status} + +
+ +
+
+

Type

+

{c.componentType}

+
+
+

Created

+

{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} - - -

+

+
+
+ +
+ + + + openEdit(c)} - > - - - - - - } - > - - - handleDelete(c)} - /> - -
- - - ))} - -
+ /> + } + > + + + handleDelete(c)} + /> + +
+
+ ))} + + {pagination && items.length < pagination.totalItems && ( + + )}
-
+ + {/* Desktop: table. */} +
+
+ + + + Code + Name + Type + Status + Created + Actions + + + + {items.map((c) => ( + + {c.code} + {c.name} + {c.componentType} + + + {c.status} + + + + {new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} + + +
+ + + + + } + > + + + handleDelete(c)} + /> + +
+
+
+ ))} +
+
+
+
+ )} {pagination && pagination.totalItems > 0 && ( -
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx index 70a6c79..e91a63b 100644 --- a/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx @@ -141,34 +141,66 @@ export default function StatutorySettingsPage() { )} {settings !== null && settings.length > 0 && ( -

-
- - - - Effective from - EPF (Employee) - EPF (Employer) - ETF (Employer) - OT multiplier - - - - {settings.map((s) => ( - - - {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"} - - {(s.epfEmployeeRate * 100).toFixed(1)}% - {(s.epfEmployerRate * 100).toFixed(1)}% - {(s.etfEmployerRate * 100).toFixed(1)}% - {s.otMultiplierDefault}x - - ))} - -
+ <> + {/* Mobile: stacked cards. */} +
+ {settings.map((s) => ( +
+

+ {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"} +

+
+
+

EPF (Employee)

+

{(s.epfEmployeeRate * 100).toFixed(1)}%

+
+
+

EPF (Employer)

+

{(s.epfEmployerRate * 100).toFixed(1)}%

+
+
+

ETF (Employer)

+

{(s.etfEmployerRate * 100).toFixed(1)}%

+
+
+

OT multiplier

+

{s.otMultiplierDefault}x

+
+
+
+ ))}
-
+ + {/* Desktop: table. */} +
+
+ + + + Effective from + EPF (Employee) + EPF (Employer) + ETF (Employer) + OT multiplier + + + + {settings.map((s) => ( + + + {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"} + + {(s.epfEmployeeRate * 100).toFixed(1)}% + {(s.epfEmployerRate * 100).toFixed(1)}% + {(s.etfEmployerRate * 100).toFixed(1)}% + {s.otMultiplierDefault}x + + ))} + +
+
+
+ )} @@ -225,28 +257,46 @@ export default function StatutorySettingsPage() { )} {slabs !== null && slabs.length > 0 && ( -
-
- - - - Effective from - Range - Rate - - - - {slabs.map((s) => ( - - {new Date(s.effectiveFrom).toLocaleDateString()} - {s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"} - {(s.rate * 100).toFixed(1)}% - - ))} - -
+ <> + {/* Mobile: stacked cards. */} +
+ {slabs.map((s) => ( +
+
+

{new Date(s.effectiveFrom).toLocaleDateString()}

+

{(s.rate * 100).toFixed(1)}%

+
+

+ {s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"} +

+
+ ))}
-
+ + {/* Desktop: table. */} +
+
+ + + + Effective from + Range + Rate + + + + {slabs.map((s) => ( + + {new Date(s.effectiveFrom).toLocaleDateString()} + {s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"} + {(s.rate * 100).toFixed(1)}% + + ))} + +
+
+
+ )}
diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx index 9406dd6..4005411 100644 --- a/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx +++ b/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx @@ -23,6 +23,9 @@ import { toast } from "@/components/ui/toast" const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 function maskToDays(mask: number): boolean[] { return DAYS.map((_, i) => (mask & (1 << i)) !== 0) @@ -37,6 +40,7 @@ export default function WorkShiftsPage() { const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) const [open, setOpen] = useState(false) const [code, setCode] = useState("") @@ -75,6 +79,12 @@ export default function WorkShiftsPage() { setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps @@ -218,7 +228,7 @@ export default function WorkShiftsPage() {
New Shift} /> - + New Work Shift Standard hours, grace period, and working days for this shift. @@ -287,7 +297,7 @@ export default function WorkShiftsPage() {
!v && setEditItem(null)}> - + Edit Work Shift Code can't be changed after creation. @@ -356,9 +366,9 @@ export default function WorkShiftsPage() {
-
- - +
@@ -427,102 +437,199 @@ export default function WorkShiftsPage() { )} {!error && items !== null && items.length > 0 && ( -
-
- - - - Code - Name - Hours - Status - Created - Actions - - - - {items.map((w) => ( - - {w.code} - {w.name} - {w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)} - - - {w.status} - - - + <> + {/* Mobile: stacked cards. */} +
+ {items.map((w) => ( +
+
+
+

{w.name}

+ {w.code} +
+ + {w.status} + +
+ +
+
+

Hours

+

{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}

+
+
+

Created

+

{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} - - -

+

+
+
+ +
+ + + + openEdit(w)} - > - - - - - - } - > - - - handleDelete(w)} - /> - -
- - - ))} - -
+ /> + } + > + + + handleDelete(w)} + /> + +
+
+ ))} + + {pagination && items.length < pagination.totalItems && ( + + )}
-
+ + {/* Desktop: table. */} +
+
+ + + + Code + Name + Hours + Status + Created + Actions + + + + {items.map((w) => ( + + {w.code} + {w.name} + {w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)} + + + {w.status} + + + + {new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} + + +
+ + + + + } + > + + + handleDelete(w)} + /> + +
+
+
+ ))} +
+
+
+
+ )} {pagination && pagination.totalItems > 0 && ( -
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/ledgers/balance-sheet/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/balance-sheet/page.tsx index d835278..0b3567c 100644 --- a/Frontend/erp-system/app/dashboard/ledgers/balance-sheet/page.tsx +++ b/Frontend/erp-system/app/dashboard/ledgers/balance-sheet/page.tsx @@ -112,7 +112,7 @@ export default function BalanceSheetPage() { )} {!error && report !== null && !isEmpty && ( -

+
diff --git a/Frontend/erp-system/app/dashboard/ledgers/general-ledger/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/general-ledger/page.tsx index a055822..ddaaa32 100644 --- a/Frontend/erp-system/app/dashboard/ledgers/general-ledger/page.tsx +++ b/Frontend/erp-system/app/dashboard/ledgers/general-ledger/page.tsx @@ -103,39 +103,39 @@ export default function GeneralLedgerReportPage() { )} {!error && rows !== null && rows.length > 0 && ( -
+
- - - - Account Code - Account Name - Date - Description - Debit - Credit - - - - - {/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every - leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line — - lands on adjacent rows instead of being scattered under separate account blocks. */} - {[...rows] - .sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo)) - .map((row, i) => ( - - {row.accountCode} - {row.accountName} - {formatReportDate(row.entryDate)} - {row.narration ?? "—"} - {formatAmount(row.debitAmount, true)} - {formatAmount(row.creditAmount, true)} - - - ))} - -
+
+ + + + Account Code + Account Name + Date + Description + Debit + Credit + + + + {/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every + leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line — + lands on adjacent rows instead of being scattered under separate account blocks. */} + {[...rows] + .sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo)) + .map((row, i) => ( + + {row.accountCode} + {row.accountName} + {formatReportDate(row.entryDate)} + {row.narration ?? "—"} + {formatAmount(row.debitAmount, true)} + {formatAmount(row.creditAmount, true)} + + ))} + +
+
)}
diff --git a/Frontend/erp-system/app/dashboard/ledgers/profit-and-loss/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/profit-and-loss/page.tsx index f6be336..67c2d0e 100644 --- a/Frontend/erp-system/app/dashboard/ledgers/profit-and-loss/page.tsx +++ b/Frontend/erp-system/app/dashboard/ledgers/profit-and-loss/page.tsx @@ -127,7 +127,7 @@ export default function ProfitAndLossPage() { )} {!error && report !== null && !isEmpty && ( -
+
+
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller identity block (company name/address/TIN/BRN) that today lives only in GL's own appsettings.json, with no endpoint exposing it. Deliberately not fabricated here — diff --git a/Frontend/erp-system/app/dashboard/ledgers/trial-balance/page.tsx b/Frontend/erp-system/app/dashboard/ledgers/trial-balance/page.tsx index b2640a4..74823ca 100644 --- a/Frontend/erp-system/app/dashboard/ledgers/trial-balance/page.tsx +++ b/Frontend/erp-system/app/dashboard/ledgers/trial-balance/page.tsx @@ -97,36 +97,38 @@ export default function TrialBalancePage() { )} {!error && rows !== null && rows.length > 0 && ( -
+
- - - - Account - Debit - Credit - - - - {rows.map((row, i) => ( - - - {row.accountCode}{" "} - {row.accountName} - - {formatAmount(row.debit, true)} - {formatAmount(row.credit, true)} +
+
+ + + Account + Debit + Credit - ))} - - - - Total - {formatAmount(totalDebit)} - {formatAmount(totalCredit)} - - -
+ + + {rows.map((row, i) => ( + + + {row.accountCode}{" "} + {row.accountName} + + {formatAmount(row.debit, true)} + {formatAmount(row.credit, true)} + + ))} + + + + Total + {formatAmount(totalDebit)} + {formatAmount(totalCredit)} + + + +
)}
diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index f00eb47..9e22166 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { AlertTriangle, + ArrowDownLeft, + ArrowUpRight, BadgeDollarSign, Boxes, ClipboardList, @@ -168,7 +170,7 @@ export default function DashboardPage() { {loaded ? ( (stats.stockValuationByWarehouse ?? []).length > 0 ? ( -
+
warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` @@ -186,7 +188,7 @@ export default function DashboardPage() {

No stock on hand yet.

) ) : ( - !error && + !error && )}
@@ -199,7 +201,7 @@ export default function DashboardPage() {
{loaded ? ( -
+
) : ( - !error && + !error && )}
@@ -227,48 +229,111 @@ export default function DashboardPage() { {loaded ? ( movements.length > 0 ? ( - - - - Item - Warehouse - Direction - Qty - Source - Date - - - + <> + {/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */} +
{movements.map((entry) => ( - - {entry.itemId} - - {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} - - +
+
+
+
+ {entry.direction === "In" ? ( + + ) : ( + + )} +
+
+

Item #{entry.itemId}

+

+ {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} +

+
+
{entry.direction} - - {entry.qtyBase} - - {entry.sourceDocType} #{entry.sourceDocId} - - - {new Date(entry.createdAt).toLocaleDateString()} - - +
+ +
+
+

Qty

+

{entry.qtyBase}

+
+
+

Date

+

{new Date(entry.createdAt).toLocaleDateString()}

+
+
+

Source

+

+ {entry.sourceDocType} #{entry.sourceDocId} +

+
+
+
))} - -
+
+ + {/* Desktop: table. */} +
+
+ + + + Item + Warehouse + Direction + Qty + Source + Date + + + + {movements.map((entry) => ( + + {entry.itemId} + + {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} + + + + {entry.direction} + + + {entry.qtyBase} + + {entry.sourceDocType} #{entry.sourceDocId} + + + {new Date(entry.createdAt).toLocaleDateString()} + + + ))} + +
+
+
+ ) : (

No stock movements yet.

) diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx index 5a7f04b..bbbc234 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx @@ -126,7 +126,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise< return (
-
+
@@ -136,7 +136,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<

{invoice.docNo}

-
+
{invoice.status !== "Draft" && ( @@ -157,7 +157,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise< {error &&
{error}
} -
+
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx index 8c64ace..3aa50b6 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx @@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
-
- +
+ Cancel -
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 090440e..b4c4e91 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx @@ -3,10 +3,11 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText } from "lucide-react" +import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText, TrendingUp } from "lucide-react" import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { branchesHrmApi } from "@/lib/api/hrm-masters" +import { dashboardApi } from "@/lib/api/dashboard" import { errorMessage } from "@/lib/error-map" import { useAuth } from "@/components/auth/AuthProvider" import { Badge } from "@/components/ui/badge" @@ -16,10 +17,12 @@ import { PaginationFooter } from "@/components/ui/pagination-footer" 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" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PaginationMeta } from "@/types/common" import { Branch } from "@/types/hrm" +import { MonthlyPurchasesVsSales } from "@/types/dashboard" import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices" import { cn } from "@/lib/utils" @@ -47,6 +50,15 @@ export default function PurchaseInvoicesPage() { const [confirmingId, setConfirmingId] = useState(null) const [payDialogInvoice, setPayDialogInvoice] = useState(null) const [payDialogOpen, setPayDialogOpen] = useState(false) + const [monthlyPurchases, setMonthlyPurchases] = useState(null) + + useEffect(() => { + const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined + dashboardApi + .stats({ branchId }) + .then((res) => setMonthlyPurchases(res.monthlyPurchasesVsSales)) + .catch(() => setMonthlyPurchases([])) + }, [activeBranchId, branchingEnabled]) useEffect(() => { const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) @@ -142,6 +154,30 @@ export default function PurchaseInvoicesPage() {
+
+
+ +

Monthly Purchases

+
+ + {monthlyPurchases === null ? ( + + ) : monthlyPurchases.length > 0 ? ( +
+ + new Date(`${m.month}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" }) + )} + datasets={[ + { label: "Purchases", data: monthlyPurchases.map((m) => m.purchases), backgroundColor: "#E11D48" }, + ]} + /> +
+ ) : ( +

No posted invoices yet.

+ )} +
+
@@ -192,7 +228,99 @@ export default function PurchaseInvoicesPage() { {!error && rows !== null && visibleRows.length > 0 && ( <> -
+
+ {visibleRows.map((row) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+ + {row.docNo} + +

{row.vendorSnapshotName}

+
+
+ + {row.status !== "Draft" && ( + + )} +
+
+ +
+
+

Vendor invoice no.

+

{row.vendorInvoiceNo}

+
+
+

Date

+

{new Date(row.vendorInvoiceDate).toLocaleDateString()}

+
+
+

GRNs

+

{row.grnCount}

+
+
+

Total

+

{row.totals.grandTotal.toFixed(2)}

+
+ {isAdmin && branchingEnabled && ( +
+

Branch

+ {branchName(row.branchId)} +
+ )} +
+ + {(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && ( +
+ {row.status === "Draft" && ( + + )} + {row.totals.balanceAmount > 0 && ( + + )} +
+ )} +
+ ))} +
+ +
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 f047adc..80612a0 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 @@ -322,16 +322,16 @@ export default function PurchaseOrderDetailPage() { -
+
{po.status === "Draft" && ( <> - } + render={ - @@ -362,6 +362,7 @@ export default function PurchaseOrderDetailPage() { -
@@ -437,7 +438,7 @@ export default function PurchaseOrderDetailPage() { )}
-
+

Lines

{editable && (
-
+ {/* Mobile: stacked cards — read-only summary or editable fields depending on `editable`. */} +
+ {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + + if (!editable) { + return ( +
+

{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}

+
+
+

UOM

+

{baseUomLabel(items, uoms, line.itemId)}

+
+
+

Warehouse

+

{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}

+
+
+

Qty / Received

+

{line.qty} / {line.qtyReceived}

+
+
+

Unit price / Tax

+

{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%

+
+
+
+ ) + } + + return ( +
+
+
+ Item + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {items.map((i) => ( + + {i.sku} — {i.name} + + ))} + + + +
+ +
+ +
+
+ UOM +
+ {baseUomLabel(items, uoms, line.itemId)} +
+
+
+ Warehouse + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {warehouses.map((w) => ( + + {w.code} + + ))} + + + +
+
+ +
+
+ Qty (received {line.qtyReceived}) + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + +
+
+ Unit price + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + +
+
+ +
+ Tax + updateLine(line.key, { tax: e.target.value })} + className="h-11 w-full text-base sm:w-32" + /> + +
+
+ ) + })} +
+ + {/* Desktop: table. */} +
@@ -574,11 +716,11 @@ export default function PurchaseOrderDetailPage() { {editable && ( -
- - 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 3aa4b0c..f8d9f76 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 @@ -430,9 +430,9 @@ function NewPurchaseOrderContent() { )}
-
+

Lines

-
+
{lines.length > 0 && ( -
-
- - - Item - UOM - Warehouse - Qty - Unit price - - - - - {lines.map((line) => { - const errors = lineErrors[line.key] ?? {} - const item = itemFor(line.itemId) - return ( - - + <> + {/* Mobile: stacked editable cards, one per line. */} +
+ {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( +
+
+
+ {requisitionId || rfqId ? ( -
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+
+ {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} +
) : ( <> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> @@ -493,13 +487,28 @@ function NewPurchaseOrderContent() { )} - - +
+ +
+ +
+
+
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
- - +
+
+ value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> @@ -513,42 +522,144 @@ function NewPurchaseOrderContent() { - - +
+
+ +
+
+ updateLine(line.key, { qty: e.target.value })} className="h-11 text-base" /> - - +
+
+ updateLine(line.key, { unitPrice: e.target.value })} className="h-11 text-base" /> - - - - - - ) - })} - -
-
+
+
+
+ ) + })} +
+ + {/* Desktop: table. */} +
+ + + + Item + UOM + Warehouse + Qty + Unit price + + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( + + + {requisitionId || rfqId ? ( +
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + )} +
+ +
+ {baseUomLabel(items ?? [], uoms ?? [], line.itemId)} +
+
+ + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {(warehouses ?? []).map((w) => ( + + {w.code} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + + + + + +
+ ) + })} +
+
+
+ )}
@@ -556,14 +667,14 @@ function NewPurchaseOrderContent() {
{submitError}
)} -
- +
+ Cancel - -
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 825dfd3..71ba5f7 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { ChevronLeft, ChevronRight, Clock, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react" +import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { vendorsApi } from "@/lib/api/vendors" @@ -30,7 +30,10 @@ import { PoStatusBadge } from "@/components/procurement/status-badges" type StatusFilter = PurchaseOrderStatus | "All" -const PAGE_SIZE = 25 +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function PurchaseOrdersListPage() { const router = useRouter() @@ -46,8 +49,10 @@ export default function PurchaseOrdersListPage() { const [query, setQuery] = useState("") const [status, setStatus] = useState("All") const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [deletingId, setDeletingId] = useState(null) const [submittingId, setSubmittingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) useEffect(() => { const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) @@ -59,15 +64,21 @@ export default function PurchaseOrdersListPage() { function load() { setError(null) purchaseOrdersApi - .list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status }) + .list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status }) .then((res) => { setPos(res.items) setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) } - useEffect(load, [page, query, status]) + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(load, [page, pageSize, query, status]) useEffect(() => { vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) }, []) @@ -124,12 +135,15 @@ export default function PurchaseOrdersListPage() {

Purchase Orders

Draft, freely editable; submitting sends it for approval before it's issued (FR-PROC-03..05).

-
- +
+ Approvals queue - + New PO @@ -173,9 +187,21 @@ export default function PurchaseOrdersListPage() { )} {!error && pos === null && ( -
+
+
+ + +
{Array.from({ length: 5 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -189,48 +215,175 @@ export default function PurchaseOrdersListPage() { {!error && visiblePos !== null && visiblePos.length > 0 && ( <> - - - - Doc No - Vendor - Status - Grand total - {isAdmin && branchingEnabled && Branch} - Created - Actions - - + {/* Mobile: stacked cards. */} +
+ {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}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+

{po.docNo}

+

{vendorCode(po.vendorId)}

+
+ +
+ +
+
+

Grand total

+

{po.totals.currency} {po.totals.grandTotal.toFixed(2)}

+
+
+

Created

+

{new Date(po.createdAt).toLocaleDateString()}

+
+ {isAdmin && branchingEnabled && ( +
+

Branch

+ + {branchName(po.branchId)} + +
+ )} +
+ + {editable && ( +
+ + + + + + } + > + + + handleSubmit(po)} + /> + + + + } + > + + + handleDelete(po)} + /> + +
+ )} +
+ ) + })} + + {pagination && pos !== null && pos.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
+
+
+ + + Doc No + Vendor + Status + Grand total + {isAdmin && branchingEnabled && Branch} + Created + Actions + + {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} - {vendorCode(po.vendorId)} - + {vendorCode(po.vendorId)} + - {po.totals.currency} {po.totals.grandTotal.toFixed(2)} + {po.totals.currency} {po.totals.grandTotal.toFixed(2)} {isAdmin && branchingEnabled && ( - + {branchName(po.branchId)} )} - {new Date(po.createdAt).toLocaleString()} - -
+ {new Date(po.createdAt).toLocaleString()} + +
+ + + {editable && ( <>
+
+
- {pagination && } + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} )}
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx index 4c4370b..22ee546 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -210,49 +210,99 @@ function NewPurchaseReturnContent() { {selectedGrn && (

Lines received on {selectedGrn.docNo}

- - - - - Item - Received qty - Hold status - Return qty - - - - {selectedGrn.lines.map((line) => { - const item = itemFor(line.itemId) - const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } - const errors = lineErrors[line.grnLineId] ?? {} - return ( - - - toggleLine(line)} /> - - {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} - {line.qty} - + + {/* Mobile: stacked cards. */} +
+ {selectedGrn.lines.map((line) => { + const item = itemFor(line.itemId) + const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } + const errors = lineErrors[line.grnLineId] ?? {} + return ( +
+ + +
+
+

Received qty

+

{line.qty}

+
+
+

Hold status

- - - setQty(line.grnLineId, e.target.value)} - className="h-11 text-base" - /> - - - - ) - })} - -
+
+
+ +
+ + setQty(line.grnLineId, e.target.value)} + className="h-11 text-base" + /> + +
+
+ ) + })} +
+ + {/* Desktop: table. */} +
+ + + + + Item + Received qty + Hold status + Return qty + + + + {selectedGrn.lines.map((line) => { + const item = itemFor(line.itemId) + const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } + const errors = lineErrors[line.grnLineId] ?? {} + return ( + + + toggleLine(line)} /> + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + + + + + setQty(line.grnLineId, e.target.value)} + className="h-11 text-base" + /> + + + + ) + })} + +
+
)} @@ -260,11 +310,11 @@ function NewPurchaseReturnContent() {
{submitError}
)} -
- +
+ Cancel -
diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx index 86bfbeb..5ccc08c 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx @@ -9,31 +9,70 @@ import { vendorsApi } from "@/lib/api/vendors" import { warehousesApi } from "@/lib/api/warehouses" import { reasonCodesApi } from "@/lib/api/reason-codes" import { errorMessage } from "@/lib/error-map" +import { PaginationMeta } from "@/types/common" import { PurchaseReturnSummary } from "@/types/procurement" import { Vendor, Warehouse } from "@/types/master-data" import { ReasonCode } from "@/types/stock" import { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 + export default function PurchaseReturnsListPage() { const [returns, setReturns] = useState(null) const [vendors, setVendors] = useState([]) const [warehouses, setWarehouses] = useState([]) const [reasonCodes, setReasonCodes] = useState([]) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [showMoreLoading, setShowMoreLoading] = useState(false) + useEffect(() => { - Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) - .then(([r, v, w, rc]) => { - setReturns(r.items) + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query]) + + function load() { + setError(null) + purchaseReturnsApi + .list({ page, pageSize, q: query || undefined }) + .then((res) => { + setReturns(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(load, [page, pageSize, query]) + useEffect(() => { + Promise.all([vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) + .then(([v, w, rc]) => { setVendors(v.items) setWarehouses(w.items) setReasonCodes(rc.items) }) - .catch((err) => setError(errorMessage(err))) + .catch(() => {}) }, []) function vendorCode(id: number) { @@ -46,6 +85,8 @@ export default function PurchaseReturnsListPage() { return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` } + const hasFilters = query.length > 0 + return (
@@ -53,20 +94,42 @@ export default function PurchaseReturnsListPage() {

Purchase Returns

Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).

- + New Return
+
+ setSearchInput(e.target.value)} + placeholder="Search doc no., vendor…" + className="h-12 w-full flex-1 basis-0 text-base" + aria-label="Search purchase returns" + /> +
+ {error && (
{error}
)} {!error && returns === null && ( -
+
+
+ + +
{Array.from({ length: 4 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -74,45 +137,107 @@ export default function PurchaseReturnsListPage() { {!error && returns !== null && returns.length === 0 && (
-

No purchase returns yet.

- - - New Return - +

{hasFilters ? "No purchase returns match your search." : "No purchase returns yet."}

+ {!hasFilters && ( + + + New Return + + )}
)} {!error && returns !== null && returns.length > 0 && ( - - - - Doc No - Vendor - Warehouse - Reason - Status - GL Journal - Created - - - + <> + {/* Mobile: stacked cards. */} +
{returns.map((r) => ( - - {r.docNo} - {vendorCode(r.vendorId)} - {warehouseCode(r.warehouseId)} - {reasonLabel(r.reasonCodeId)} - - +
+
+
+

{r.docNo}

+

{vendorCode(r.vendorId)}

+
+ {r.status} - - {r.glJournalNo ?? "—"} - {new Date(r.createdAt).toLocaleString()} - +
+ +
+
+

Warehouse

+

{warehouseCode(r.warehouseId)}

+
+
+

Reason

+

{reasonLabel(r.reasonCodeId)}

+
+
+

GL Journal

+

{r.glJournalNo ?? "—"}

+
+
+

Created

+

{new Date(r.createdAt).toLocaleDateString()}

+
+
+
))} - -
+ + {pagination && returns !== null && returns.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Doc No + Vendor + Warehouse + Reason + Status + GL Journal + Created + + + + {returns.map((r) => ( + + {r.docNo} + {vendorCode(r.vendorId)} + {warehouseCode(r.warehouseId)} + {reasonLabel(r.reasonCodeId)} + + + {r.status} + + + {r.glJournalNo ?? "—"} + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} + )}
) diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 5679930..d79f4c2 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -193,12 +193,12 @@ export default function ItemDetailPage() { 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) + // Running totals are computed over the full history above so they stay accurate — + // only the most recent 7 points are kept for display. + return points.slice(-7) }, [sortedLedger]) - /** Qty in vs qty out per month, last 6 months with activity. */ + /** Qty in vs qty out per month, last 5 months with activity. */ const monthlyActivity = useMemo(() => { const byMonth = new Map() for (const row of sortedLedger) { @@ -208,11 +208,22 @@ export default function ItemDetailPage() { else bucket.out += row.qtyBase byMonth.set(key, bucket) } - return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6) + return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5) }, [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. + // Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the + // narrow card instead of leaving blank trailing slots — there's less width to spare there. + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const mql = window.matchMedia("(max-width: 639px)") + setIsMobile(mql.matches) + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, []) + const [cutoff30] = useState(() => Date.now() - 30 * 24 * 60 * 60 * 1000) const salesQty30d = useMemo( () => @@ -408,7 +419,14 @@ export default function ItemDetailPage() { ) : 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) }]} + datasets={[ + { + label: `On hand (${uomName(item.baseUomId)})`, + data: stockTrend.map((p) => p.qty), + borderColor: "rgba(225,29,72,1)", + backgroundColor: "rgba(225,29,72,0.15)", + }, + ]} /> ) : (

No stock movements recorded yet.

@@ -424,11 +442,35 @@ export default function ItemDetailPage() { ) : 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)" }, + // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of + // activity, Chart.js would otherwise center the lone category across the full + // width. Trailing blank slots keep the axis/gridlines spanning the full card + // while the real bars sit at the start. Skipped on mobile — there the bars + // should stretch to fill the narrower card instead. + labels={[ + ...monthlyActivity.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })), + ...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill("")), ]} + datasets={[ + { + label: "Purchased", + data: [ + ...monthlyActivity.map(([, v]) => v.in), + ...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)), + ], + backgroundColor: "rgba(34,197,94,0.6)", + }, + { + label: "Sold", + data: [ + ...monthlyActivity.map(([, v]) => v.out), + ...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)), + ], + backgroundColor: "rgba(239,68,68,0.6)", + }, + ]} + barPercentage={0.6} + categoryPercentage={0.6} /> ) : (

No activity recorded yet.

@@ -444,37 +486,67 @@ export default function ItemDetailPage() { ) : recentSales.length === 0 ? (

No sales recorded yet.

) : ( - - - - Date - Document - Qty - Value - - - + <> + {/* Mobile: stacked cards. */} +
{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) + {docLabel(row.sourceDocType, row.sourceDocId)} )} - - {row.qtyBase.toLocaleString()} - LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })} - + {new Date(row.createdAt).toLocaleDateString()} +
+
+ Qty {row.qtyBase.toLocaleString()} + LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+
) })} - -
+
+ + {/* Desktop: table. */} +
+ + + + 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 })} + + ) + })} + +
+
+ )}
@@ -485,30 +557,53 @@ export default function ItemDetailPage() { ) : recentGrns.length === 0 ? (

No goods received yet.

) : ( - - - - Date - Document - Qty - Value - - - + <> + {/* Mobile: stacked cards. */} +
{recentGrns.map((row) => ( - - {new Date(row.createdAt).toLocaleDateString()} - - +
+
+ {docLabel(row.sourceDocType, row.sourceDocId)} - - {row.qtyBase.toLocaleString()} - LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })} - + {new Date(row.createdAt).toLocaleDateString()} +
+
+ Qty {row.qtyBase.toLocaleString()} + LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+
))} - -
+
+ + {/* Desktop: table. */} +
+ + + + 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 })} + + ))} + +
+
+ )}
diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx index 998ace3..f43be39 100644 --- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -214,14 +214,15 @@ function BrandPanel() { key={b.brandId} onDoubleClick={() => openEdit(b)} title="Tick to activate/deactivate — double-click to rename" - className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50" + className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50" > handleToggleActive(b)} disabled={togglingId === b.brandId} + className="shrink-0" /> - {b.name} + {b.name} ))}
@@ -356,14 +357,15 @@ function UomPanel() { ))}
diff --git a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx index f9814c9..9b515ee 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx @@ -166,71 +166,139 @@ export default function CategorySubCategoriesPage() { )} {!error && subCategories !== null && subCategories.length > 0 && ( - - - - ID - Name - Status - Created At - Actions - - - + <> + {/* Mobile: stacked cards. */} +
{subCategories.map((s) => ( - - {s.subCategoryId} - {s.name} - +
+
+
+

{s.name}

+

#{s.subCategoryId}

+
{s.status} - - {new Date(s.createdAt).toLocaleDateString()} - -
- +
- - - } - > - {s.status === "Active" ? : } - - handleToggleStatus(s)} - /> - -
- - +
+

Created

+

{new Date(s.createdAt).toLocaleDateString()}

+
+ +
+ + + + + } + > + {s.status === "Active" ? : } + + handleToggleStatus(s)} + /> + +
+
))} - -
+
+ + {/* Desktop: table. */} +
+
+ + + + ID + Name + Status + Created At + Actions + + + + {subCategories.map((s) => ( + + {s.subCategoryId} + {s.name} + + + {s.status} + + + {new Date(s.createdAt).toLocaleDateString()} + +
+ + + + + } + > + {s.status === "Active" ? : } + + handleToggleStatus(s)} + /> + +
+
+
+ ))} +
+
+
+
+ )}
) diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 306475d..33354c0 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -264,101 +264,185 @@ export default function CategoriesPage() { {!error && categories !== null && categories.length > 0 && ( <> - - - - - toggleSort("categoryId")} /> - - - toggleSort("name")} /> - - - toggleSort("status")} /> - - - toggleSort("createdAt")} /> - - {config?.subcategoriesEnabled && Subcategories} - Actions - - - - {sortedCategories!.map((c) => ( - - {c.categoryId} - {c.name} - - - {c.status} - - - {new Date(c.createdAt).toLocaleDateString()} + {/* Mobile: stacked cards. */} +
+ {sortedCategories!.map((c) => ( +
+
+
+

{c.name}

+

#{c.categoryId}

+
+ + {c.status} + +
+ +
+
+

Created

+

{new Date(c.createdAt).toLocaleDateString()}

+
{config?.subcategoriesEnabled && ( - {subCategoryCounts[c.categoryId] ?? "—"} - )} - -
- {/* Subcategories are their own resource now, not a nested tree. - Hidden while the feature is switched off in Product Configuration. */} - {config?.subcategoriesEnabled && ( - - - - )} - - - - {/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */} - - - } - > - {c.status === "Active" ? : } - - handleToggleStatus(c)} - /> - +
+

Subcategories

+

{subCategoryCounts[c.categoryId] ?? "—"}

- - - ))} - -
+ )} + + +
+ {config?.subcategoriesEnabled && ( + + + + )} + + + + + + } + > + {c.status === "Active" ? : } + + handleToggleStatus(c)} + /> + +
+ + ))} + + + {/* Desktop: table. */} +
+
+ + + + + toggleSort("categoryId")} /> + + + toggleSort("name")} /> + + + toggleSort("status")} /> + + + toggleSort("createdAt")} /> + + {config?.subcategoriesEnabled && Subcategories} + Actions + + + + {sortedCategories!.map((c) => ( + + {c.categoryId} + {c.name} + + + {c.status} + + + {new Date(c.createdAt).toLocaleDateString()} + {config?.subcategoriesEnabled && ( + {subCategoryCounts[c.categoryId] ?? "—"} + )} + +
+ {/* Subcategories are their own resource now, not a nested tree. + Hidden while the feature is switched off in Product Configuration. */} + {config?.subcategoriesEnabled && ( + + + + )} + + + + {/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */} + + + } + > + {c.status === "Active" ? : } + + handleToggleStatus(c)} + /> + +
+
+
+ ))} +
+
+
+
{pagination && } diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index c9670cd..04f5de5 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -9,6 +9,7 @@ import { ArrowUp, ArrowUpDown, Download, + Eye, ListTree, Package, Pencil, @@ -41,7 +42,7 @@ type StatusFilter = EntityStatus | "All" type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate" type SortOrder = "asc" | "desc" -const PAGE_SIZE = 25 +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const COLUMN_OPTIONS = [ { key: "categoryBrand", label: "Category / Brand" }, @@ -82,6 +83,7 @@ export default function ItemsPage() { const [status, setStatus] = useState("All") const [categoryId, setCategoryId] = useState("All") const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [selected, setSelected] = useState>(new Set()) const [sortKey, setSortKey] = useState("sku") @@ -100,7 +102,7 @@ export default function ItemsPage() { itemsApi .overview({ page, - pageSize: PAGE_SIZE, + pageSize, q: query || undefined, status: status === "All" ? undefined : status, categoryId: categoryId === "All" ? undefined : categoryId, @@ -113,7 +115,7 @@ export default function ItemsPage() { .catch((err) => setError(errorMessage(err))) } - useEffect(load, [page, query, status, categoryId]) + useEffect(load, [page, pageSize, query, status, categoryId]) useEffect(() => { categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) @@ -208,7 +210,7 @@ export default function ItemsPage() {

Items

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

-
+
UOMs @@ -296,7 +298,109 @@ export default function ItemsPage() { {!error && sortedItems !== null && sortedItems.length > 0 && ( <> -
+
+ {sortedItems.map((item) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/products/${item.itemId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/products/${item.itemId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+ toggleRow(item.itemId, c === true)} + aria-label={`Select ${item.sku}`} + data-no-row-nav + className="mt-0.5 shrink-0" + /> +
+ +
+
+

{item.name}

+

{item.sku}

+
+
+ {showCol("status") && ( + + {item.status} + + )} +
+ + {showCol("categoryBrand") && ( +

+ {categoryName(item.categoryId)} + {config?.brandsEnabled && ` · ${brandName(item.brandId)}`} +

+ )} + +
+ {showCol("stockValue") && ( +
+

Stock Value

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

{item.stockQty.toLocaleString()} on hand

+
+ )} + {showCol("sales30d") && ( +
+

Sales (30d)

+

{item.salesQtyLast30Days.toLocaleString()}

+
+ )} + {showCol("lastPurchase") && ( +
+

Last Purchase

+ {item.lastPurchaseDate ? ( + <> +

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

+

{item.lastSupplierName ?? "—"}

+ + ) : ( +

—

+ )} +
+ )} +
+ + + + Edit + +
+ ))} +
+ +
@@ -386,13 +490,24 @@ export default function ItemsPage() { )} - - - +
+ + + + + + +
))} @@ -400,7 +515,14 @@ export default function ItemsPage() {
- {pagination && } + {pagination && ( + { setPage(1); setPageSize(size) }} + /> + )} )}
diff --git a/Frontend/erp-system/app/dashboard/products/settings/page.tsx b/Frontend/erp-system/app/dashboard/products/settings/page.tsx index ea6084b..6ed533a 100644 --- a/Frontend/erp-system/app/dashboard/products/settings/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/settings/page.tsx @@ -92,7 +92,7 @@ export default function ProductSettingsPage() { {!error && !config && } {!error && config && ( -
+

Product Capabilities

diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 2ba0529..b7bd6a8 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react" import { useParams } from "next/navigation" import Link from "next/link" -import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react" +import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react" import { grnsApi } from "@/lib/api/grns" import { warehousesApi } from "@/lib/api/warehouses" @@ -154,11 +154,11 @@ export default function GrnDetailPage() {
-
+
{grn.status !== "Draft" && ( @@ -166,7 +166,7 @@ export default function GrnDetailPage() { )} {grn.status === "Draft" && ( - @@ -193,7 +193,110 @@ export default function GrnDetailPage() {
)} -
+ {/* Mobile: stacked cards. */} +
+ {grn.lines.map((line) => { + const item = itemFor(line.itemId) + return ( +
+
+
+
+

{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}

+ {item?.warranty === "Warranty" && Warranty} +
+ {line.warrantyNumbers.length > 0 && ( +

+ {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured +

+ )} +
+ +
+ +
+
+

UOM / Bin

+

{baseUomLabel(items, uoms, line.itemId)} · {binFor(line.binId)}

+
+
+

Qty

+

{line.qty}

+
+
+

Unit cost

+

+ {line.unitCost.toFixed(2)} + {line.poUnitPrice !== null && line.priceVariance !== 0 && ( + + PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)} + + )} +

+
+
+

Disc % / Net cost

+

{line.discountPct.toFixed(2)}% / {line.netUnitCost.toFixed(2)}

+
+
+

Received value

+

{line.receivedValue.toFixed(2)}

+
+
+

VAT

+

{line.vatAmount.toFixed(2)} ({line.vatPct.toFixed(2)}%)

+
+
+

Line total

+

{line.lineTotal.toFixed(2)}

+
+
+ + {grn.status === "Confirmed" && ( +
+ {line.holdStatus === "OnHold" ? ( +
+ + +
+ ) : line.holdStatus === "Rejected" ? ( + + + Create Return + + ) : ( + No actions + )} +
+ )} +
+ ) + })} +
+ + {/* Desktop: table. */} +
@@ -339,9 +442,9 @@ export default function GrnDetailPage() { This GRN hasn't been added to a purchase invoice yet. - + Create Purchase Invoice diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index cd3d2cf..c4a6391 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -834,7 +834,190 @@ export default function NewGrnPage() { {poLoading && } {!poLoading && lineTab === "lines" && lines.length > 0 && ( -
+ <> + {/* Mobile: stacked editable cards. */} +
+ {lines.map((line) => { + const item = itemFor(line.itemId) + const errors = lineErrors[line.key] ?? {} + const c = computeLine(line) + return ( +
+
+
+ + {line.poLineId ? ( +
+ {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {item?.warranty === "Warranty" && Warranty} +
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + {i.warranty === "Warranty" ? " (Warranty)" : ""} + + ))} + + + {item?.warranty === "Warranty" && ( + Warranty + )} + + + )} +
+ +
+ +
+
+ +
+ {baseUomLabel(items ?? [], uoms ?? [], line.itemId)} +
+
+
+ + value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> + + + + + {bins.map((b) => ( + + {b.code} + + ))} + + +
+
+ + {item?.trackingMode === "Batch" && ( +
+ + updateLine(line.key, { batchNo: e.target.value })} + placeholder="Batch no" + className="h-11 text-sm" + /> +
+ )} + +
+
+ + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-sm" + /> + +
+
+ + updateLine(line.key, { unitCost: e.target.value })} + className="h-11 text-sm" + /> + + {line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && ( +

PO price {line.poUnitPrice.toFixed(2)}

+ )} +
+
+ +
+
+ + updateLine(line.key, { discountPct: e.target.value })} + disabled={headerDiscountPct.trim() !== ""} + className="h-11 text-sm" + /> + +
+
+ + updateLine(line.key, { vatPct: e.target.value })} + disabled={headerVatPct.trim() !== ""} + className="h-11 text-sm" + /> + +
+
+ +
+ + value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}> + + + + + Available + On hold (inspection) + + +
+ + {item?.warranty === "Warranty" && ( + + )} + {errors.warrantyNumbers && } + +
+ Line total +
+ {c.lineTotal.toFixed(2)} +

net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}

+
+
+
+ ) + })} +
+ + {/* Desktop: table. */} +
@@ -1043,7 +1226,8 @@ export default function NewGrnPage() { })}
-
+
+ )} {!poLoading && lineTab === "warranty" && ( @@ -1179,11 +1363,11 @@ export default function NewGrnPage() { )} {lineTab === "lines" && ( -
- +
+ Cancel -
diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx index 2f0b1f8..7b886e4 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx @@ -3,11 +3,14 @@ import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { ChevronLeft, ChevronRight, Clock, PackageSearch, Plus, Search } from "lucide-react" +import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react" import { grnsApi } from "@/lib/api/grns" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { GrnStatus, GrnSummary } from "@/types/grn" +import { Vendor, Warehouse } from "@/types/master-data" import { PaginationMeta } from "@/types/common" import { cn } from "@/lib/utils" import { useAuth } from "@/components/auth/AuthProvider" @@ -31,7 +34,10 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges" type StatusFilter = GrnStatus | "All" -const PAGE_SIZE = 25 +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function GrnListPage() { const router = useRouter() @@ -39,6 +45,8 @@ export default function GrnListPage() { const stats = useDashboardStats() const [grns, setGrns] = useState(null) + const [vendors, setVendors] = useState([]) + const [warehouses, setWarehouses] = useState([]) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -46,6 +54,22 @@ export default function GrnListPage() { const [query, setQuery] = useState("") const [status, setStatus] = useState("All") const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [showMoreLoading, setShowMoreLoading] = useState(false) + + useEffect(() => { + vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) + warehousesApi.list({ pageSize: 200 }).then((res) => setWarehouses(res.items)).catch(() => {}) + }, []) + + function vendorName(id: number) { + const vendor = vendors.find((v) => v.vendorId === id) + return vendor ? `${vendor.code} — ${vendor.name}` : `#${id}` + } + function warehouseName(id: number) { + const warehouse = warehouses.find((w) => w.warehouseId === id) + return warehouse ? `${warehouse.code} — ${warehouse.name}` : `#${id}` + } // Debounce the search box so typing doesn't refetch on every keystroke. @@ -64,7 +88,7 @@ export default function GrnListPage() { grnsApi .list({ page, - pageSize: PAGE_SIZE, + pageSize, q: query || undefined, status: status === "All" ? undefined : status, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, @@ -74,9 +98,15 @@ export default function GrnListPage() { setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) } - useEffect(load, [page, query, status, activeBranchId, branchingEnabled]) + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled]) const hasFilters = query.length > 0 || status !== "All" @@ -89,7 +119,7 @@ export default function GrnListPage() { Receive goods against a purchase order, or record a direct receipt.

- + New GRN @@ -142,9 +172,21 @@ export default function GrnListPage() { )} {!error && grns === null && ( -
+
+
+ + +
{Array.from({ length: 5 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -178,62 +220,167 @@ export default function GrnListPage() { {!error && grns !== null && grns.length > 0 && ( <> - - - - Doc No - PO - Vendor - Warehouse - Status - Invoice - Created - - - - {grns.map((grn) => { - return ( - { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/receiving/grn/${grn.grnId}`) - }} - > - - - {grn.docNo} - - - {grn.poId ?? Direct} - {grn.vendorId} - {grn.warehouseId} - - - - + {/* Mobile: stacked cards. */} +
+ {grns.map((grn) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/receiving/grn/${grn.grnId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+

{grn.docNo}

+

{vendorName(grn.vendorId)}

+
+ +
+ +
+
+

PO

+

+ {grn.poId ? ( + + PO {String(grn.poId).padStart(2, "0")} + + ) : ( + "Direct" + )} +

+
+
+

Warehouse

+

{warehouseName(grn.warehouseId)}

+
+
+

Invoice

+

{grn.status !== "Confirmed" ? ( - — + "—" ) : grn.purchaseInvoiceId ? ( - + {grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`} ) : ( - Not invoiced + "Not invoiced" )} - - {new Date(grn.createdAt).toLocaleString()} - - ) - })} - -

+

+
+
+

Created

+

{new Date(grn.createdAt).toLocaleDateString()}

+
+
+
+ ))} - {pagination && } + {pagination && grns.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Doc No + PO + Vendor + Warehouse + 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.docNo} + + + + {grn.poId ? ( + + PO {String(grn.poId).padStart(2, "0")} + + ) : ( + Direct + )} + + {vendorName(grn.vendorId)} + {warehouseName(grn.warehouseId)} + + + + + {grn.status !== "Confirmed" ? ( + — + ) : grn.purchaseInvoiceId ? ( + + {grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`} + + ) : ( + Not invoiced + )} + + {new Date(grn.createdAt).toLocaleString()} + +
+ + + +
+
+
+ ) + })} +
+
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} )}
diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/templates/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/templates/page.tsx index cb4bf82..94ba143 100644 --- a/Frontend/erp-system/app/dashboard/sales/bundles/templates/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/bundles/templates/page.tsx @@ -2,12 +2,22 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { ArrowLeft, Plus, Save, ShoppingBag, Trash2 } from "lucide-react" +import { ArrowLeft, Plus, Save, ShoppingBag } from "lucide-react" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { Combobox } from "@/components/ui/combobox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { cn } from "@/lib/utils" @@ -18,9 +28,15 @@ import { uomsApi } from "@/lib/api/uoms" import { baseUomLabel } from "@/lib/uom-label" import { warehousesApi } from "@/lib/api/warehouses" import { toast } from "@/components/ui/toast" +import { PaginationMeta } from "@/types/common" import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 + type Line = CreateBundleSaleTemplateLineRequest & { key: string } const blankLine = (key: string, warehouseId: number): Line => ({ @@ -42,13 +58,26 @@ export default function BundleTemplatesPage() { const [description, setDescription] = useState("") const [lines, setLines] = useState([]) const [templates, setTemplates] = useState([]) + const [pagination, setPagination] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [formOpen, setFormOpen] = useState(false) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) async function refreshTemplates() { - const res = await bundleApi.listTemplates({ pageSize: 50 }) + const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined }) setTemplates(res.items) + setPagination(res.pagination) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } useEffect(() => { @@ -70,6 +99,17 @@ export default function BundleTemplatesPage() { .finally(() => setLoading(false)) }, []) + useEffect(() => { + if (!loading) refreshTemplates().finally(() => setShowMoreLoading(false)) + }, [page, pageSize, query]) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query]) + const itemOptions = useMemo( () => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })), [items] @@ -84,18 +124,20 @@ export default function BundleTemplatesPage() { updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 }) } - function addLine() { - setLines((prev) => [...prev, blankLine(`line-${Date.now()}`, warehouseId ?? 0)]) - } - - function removeLine(key: string) { - setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) - } - function resetForm() { setTemplateName("") setDescription("") setLines([blankLine("line-1", warehouseId ?? 0)]) + setError(null) + } + + function openCreateForm() { + resetForm() + } + + function handleFormOpenChange(open: boolean) { + setFormOpen(open) + if (!open) resetForm() } const componentSubtotal = useMemo( @@ -124,6 +166,7 @@ export default function BundleTemplatesPage() { }) toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`) resetForm() + setFormOpen(false) await refreshTemplates() } catch (err) { setError(errorMessage(err)) @@ -148,170 +191,271 @@ export default function BundleTemplatesPage() {

Define a reusable bundle of items — once saved, it's ready to sell from Create Sale.

- - + + + + New Bundle Template + + } + /> + + + New bundle template + Template code is generated automatically when you save. + - {error ?
{error}
: null} + {error ?
{error}
: null} -
-

Bundle details

-

Template code is generated automatically when you save.

-
-
- - setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" /> -
-
- - -
-
- - setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" /> -
-
-
+ + + Template name + setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" /> + + + Warehouse + + + + Description (optional) + setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" /> + + -
-
-

Component items

- -
-
- - - - Item - UOM - Qty - Unit price - Include - - - - - {lines.map((line) => ( - - + {lines.map((line) => ( + + + Item selectItem(line.key, v ?? 0)} placeholder="Search item…" /> - - - {baseUomLabel(items, uoms, line.itemId)} - - - updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> - - - updateLine(line.key, { unitPrice: Number(e.target.value) })} className="h-9 w-28 text-right font-mono text-sm tabular-nums" /> - - + + + UOM + + + + Qty + updateLine(line.key, { qty: Number(e.target.value) })} + className="font-mono tabular-nums" + /> + + + Unit price + updateLine(line.key, { unitPrice: Number(e.target.value) })} + className="font-mono tabular-nums" + /> + + + Include in bundle - - - - - - ))} - -
-
-
- Component subtotal - {componentSubtotal.toFixed(2)} -
-
+ + + ))} + +
+ Component subtotal + {componentSubtotal.toFixed(2)} +
+ +
+ + +
+
+
+ + + {error ?
{error}
: null}
-
+

Bundle templates

Every saved bundle shows up here, and as an "Add bundle" option on Create Sale.

-
-
- - - - Template - Description - Lines - Status - Actions - - - - {templates.length > 0 ? ( - templates.map((t) => ( - - -
{t.templateName}
-
{t.templateCode}
-
- {t.description ?? "—"} - {t.lineCount} - {t.status} - - - - Sell - - -
- )) - ) : ( - - - No bundle templates yet — save one above. - - + + setSearchInput(e.target.value)} + placeholder="Search template name or code…" + className="mt-3 h-10 w-full max-w-sm text-sm" + aria-label="Search bundle templates" + /> + + {templates.length > 0 ? ( + <> + {/* Mobile: stacked cards. */} +
+ {templates.map((t) => ( +
+
+
+

{t.templateName}

+

{t.templateCode}

+
+ + {t.status} + +
+ + {t.description &&

{t.description}

} + +
+
+

Lines

+

{t.lineCount}

+
+
+

Created

+

{new Date(t.createdAt).toLocaleDateString()}

+
+
+ + + + Sell + +
+ ))} + + {pagination && templates.length < pagination.totalItems && ( + )} - -
-
+
+ + {/* Desktop: table. */} +
+
+ + + + Template + Description + Lines + Status + Created + Updated + Actions + + + + {templates.map((t) => ( + + +
{t.templateName}
+
{t.templateCode}
+
+ {t.description ?? "—"} + {t.lineCount} + + + {t.status} + + + {new Date(t.createdAt).toLocaleString()} + {t.updatedAt ? new Date(t.updatedAt).toLocaleString() : "—"} + +
+ + + Sell + +
+
+
+ ))} +
+
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} + + ) : ( +
+ No bundle templates yet — create one above. +
+ )}
) diff --git a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx index dd2a050..519742c 100644 --- a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx @@ -236,32 +236,45 @@ export default function SalesDayEndPage() {

Item breakdown

-
- - - - Item - Qty - Revenue - - - - {report.itemBreakdown.length === 0 ? ( - - No lines. - - ) : ( - report.itemBreakdown.map((line) => ( - - {line.sku} — {line.name} - {line.qty} - {line.revenue.toFixed(2)} + {report.itemBreakdown.length === 0 ? ( +

No lines.

+ ) : ( + <> + {/* Mobile: stacked rows. */} +
+ {report.itemBreakdown.map((line) => ( +
+ {line.sku} — {line.name} + + {line.qty} × {line.revenue.toFixed(2)} + +
+ ))} +
+ + {/* Desktop: table. */} +
+
+ + + Item + Qty + Revenue - )) - )} - -
-
+ + + {report.itemBreakdown.map((line) => ( + + {line.sku} — {line.name} + {line.qty} + {line.revenue.toFixed(2)} + + ))} + + +
+ + )} {report.slipNumbers.length > 0 && ( diff --git a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx index d720127..e8ad62c 100644 --- a/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/free-issues/new/page.tsx @@ -2,11 +2,22 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { ArrowLeft, Eraser, Pencil, Plus, Save, Trash2, X } from "lucide-react" +import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Combobox } from "@/components/ui/combobox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { PaginationFooter } from "@/components/ui/pagination-footer" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { cn } from "@/lib/utils" import { errorMessage } from "@/lib/error-map" @@ -19,10 +30,16 @@ import { warehousesApi } from "@/lib/api/warehouses" import { usersApi } from "@/lib/api/users" import { toast } from "@/components/ui/toast" import { Customer } from "@/types/customers" +import { PaginationMeta } from "@/types/common" import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { ManagedUser } from "@/types/users" import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 + type Line = CreateSalesSlipLineRequest & { key: string } type FreeIssueRow = { @@ -65,14 +82,25 @@ export default function NewFreeIssuePage() { const [cashierUserId, setCashierUserId] = useState(null) const [lines, setLines] = useState([blankLine("line-1")]) const [rows, setRows] = useState([]) + const [pagination, setPagination] = useState(null) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [editingRowId, setEditingRowId] = useState(null) const [editingLines, setEditingLines] = useState([]) + const [formOpen, setFormOpen] = useState(false) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } async function refreshRows() { - const list = await salesApi.listFreeIssues({ pageSize: 50 }) + const list = await salesApi.listFreeIssues({ page, pageSize }) + setPagination(list.pagination) const details = await Promise.all( list.items.map(async (summary) => { const detail = await salesApi.getFreeIssue(summary.salesSlipId) @@ -126,6 +154,10 @@ export default function NewFreeIssuePage() { .finally(() => setLoading(false)) }, []) + useEffect(() => { + if (!loading) refreshRows().finally(() => setShowMoreLoading(false)) + }, [page, pageSize]) + function updateLine(key: string, patch: Partial) { setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) } @@ -134,18 +166,6 @@ export default function NewFreeIssuePage() { setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) } - function addLine() { - setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)]) - } - - function removeLine(key: string) { - setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) - } - - function clearAllLines() { - setLines([blankLine("line-1")]) - } - const itemOptions = useMemo( () => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })), [items] @@ -202,6 +222,7 @@ export default function NewFreeIssuePage() { } setLines([blankLine("line-1")]) + setFormOpen(false) await refreshRows() } catch (err) { setError(errorMessage(err)) @@ -236,6 +257,7 @@ export default function NewFreeIssuePage() { parentLineId: detailLine?.parentLineId ?? null, }, ]) + setFormOpen(true) } catch (err) { setError(errorMessage(err)) } @@ -246,12 +268,25 @@ export default function NewFreeIssuePage() { setEditingLines([]) } + function openCreateForm() { + cancelEdit() + setError(null) + } + + function handleFormOpenChange(open: boolean) { + setFormOpen(open) + if (!open) cancelEdit() + } + async function deleteRow(row: FreeIssueRow) { if (row.status !== "Draft") return try { await salesApi.cancelFreeIssue(row.salesSlipId) toast.success("Free issue cancelled", row.slipNo) - if (editingRowId === row.salesSlipId) cancelEdit() + if (editingRowId === row.salesSlipId) { + cancelEdit() + setFormOpen(false) + } await refreshRows() } catch (err) { setError(errorMessage(err)) @@ -276,197 +311,254 @@ export default function NewFreeIssuePage() {

Create and manage free-issue sales slips from the backend.

- - - {error ?
{error}
: null} - - {editingRowId ? ( -
-
-

Inline edit free issue

- -
-
- - - - ID - Item - UOM - Qty - Free - - - - - {activeLines.map((line, idx) => ( - - {idx + 1} - - selectEditingItem(line.key, v ?? 0)} - placeholder="Search item…" - /> - - - {baseUomLabel(items, uoms, line.itemId)} - - - updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> - - - updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> - - editing - - ))} - -
-
-
- ) : ( -
-
-

Free issue lines

-
- - +
-
-
- - - - ID - Item - UOM - Qty - Free - - - - - {lines.map((line, idx) => ( - - {idx + 1} - - selectItem(line.key, v ?? 0)} - placeholder="Search item…" - /> - - - {baseUomLabel(items, uoms, line.itemId)} - - - updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> - - - updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> - - - - - - ))} - -
-
-
- )} + + +
-
+

Created free issues

Persisted backend records with draft-only cancellation.

-
-
- - - - Promotion - Warehouse - Product - Qty - Free - Actions - - - - {rows.length > 0 ? ( - rows.map((row) => ( - - -
Buy {row.qty} Get {row.freeQty || 0}
-
{row.slipNo}
-
- {row.warehouseName} - -
{row.itemName}
-
{row.itemSku} · {row.uomName}
-
- {row.qty} - {row.freeQty} - -
- - {row.status === "Draft" ? ( - - ) : null} -
-
-
- )) - ) : ( - - - No free issues created yet. - - + + {rows.length > 0 ? ( + <> + {/* Mobile: stacked cards. */} +
+ {rows.map((row) => ( +
+
+

Buy {row.qty} Get {row.freeQty || 0}

+

{row.slipNo}

+
+ +
+

{row.itemName}

+

{row.itemSku} · {row.uomName}

+
+ +
+
+

Warehouse

+

{row.warehouseName}

+
+
+

Qty

+

{row.qty}

+
+
+

Free

+

{row.freeQty}

+
+
+ +
+ + {row.status === "Draft" && ( + + )} +
+
+ ))} + + {pagination && rows.length < pagination.totalItems && ( + )} - -
-
+
+ + {/* Desktop: table. */} +
+
+ + + + Promotion + Warehouse + Product + Qty + Free + Actions + + + + {rows.map((row) => ( + + +
Buy {row.qty} Get {row.freeQty || 0}
+
{row.slipNo}
+
+ {row.warehouseName} + +
{row.itemName}
+
{row.itemSku} · {row.uomName}
+
+ {row.qty} + {row.freeQty} + +
+ + {row.status === "Draft" && ( + + )} +
+
+
+ ))} +
+
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} + + ) : ( +
+ No free issues created yet. +
+ )}
) 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 548e9b4..2a14c2b 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx @@ -603,7 +603,182 @@ function NewSalesInvoiceContent() { -
+ {/* Mobile: stacked cards. */} +
+ {displayRows.map((row, idx) => { + if (row.type === "bundle") { + const bundleTotal = row.lines.reduce((sum, l) => sum + lineTotals(l).net, 0) + const bundleFreeQty = row.lines.reduce((sum, l) => sum + Number(l.freeQty || 0), 0) + const bundleQty = row.lines[0]?.bundleQty ?? 1 + return ( +
+
+
+

Bundle: {row.bundleName}

+
    + {row.lines.map((l) => ( +
  • + {items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty} +
  • + ))} +
+
+ +
+ +
+
+ + updateBundleQty(row.groupId, Number(e.target.value))} + className="h-11 text-sm tabular-nums" + aria-label="Bundle quantity" + /> +
+
+ +
{bundleFreeQty || "—"}
+
+
+ +
+ Total + {lkr.format(bundleTotal)} +
+
+ ) + } + + const line = row.line + const { net: lineNet } = lineTotals(line) + const scheme = freeIssueSchemeFor(line.itemId) + + return ( +
+
+
+ + selectLineOption(line.key, v)} + placeholder="Search item or bundle…" + className="h-11 text-sm" + /> + {(line.batchNo || line.serialNo) && ( +

Lot: {line.batchNo ?? line.serialNo}

+ )} +
+ +
+ +
+
+ +
+ {baseUomLabel(items, uoms, line.itemId)} +
+
+
+ + updateQty(line.key, line.itemId, Number(e.target.value))} + className="h-11 text-sm tabular-nums" + /> +
+
+ +
+
+ + updateLine(line.key, { freeQty: Number(e.target.value) })} + className="h-11 text-sm tabular-nums" + /> + {scheme && ( +

+ {scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`} +

+ )} +
+
+ + updateLine(line.key, { discountPct: Number(e.target.value) || 0 })} + className="h-11 text-sm tabular-nums" + /> +
+
+ +
+ + updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} + className="h-11 text-sm tabular-nums" + /> +

+ {getSuggestedUnitPrice(items, line.itemId) !== null + ? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}` + : "No price suggestion available"} +

+
+ +
+ Line total + {lkr.format(lineNet)} +
+
+ ) + })} +
+ + {/* Desktop: table. */} +
@@ -773,31 +948,31 @@ function NewSalesInvoiceContent() {
-
-
+
+
Gross {lkr.format(grossTotal)}
-
+
Discount -{lkr.format(discountTotal)}
-
+
Tax {lkr.format(taxTotal)}
-
+
Payable {lkr.format(payableTotal)}
-
- +
+ Cancel -
diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx index b7c7d5b..197e48d 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -31,6 +31,9 @@ import { cn } from "@/lib/utils" type StatusFilter = SalesInvoiceStatus | "All" const PAGE_SIZE = 25 +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 25 const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [ { value: "All", label: "All" }, { value: "Draft", label: "Draft" }, @@ -50,6 +53,8 @@ export default function SalesInvoicesPage() { const [searchInput, setSearchInput] = useState("") const [query, setQuery] = useState("") const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE) + const [showMoreLoading, setShowMoreLoading] = useState(false) const [confirmingId, setConfirmingId] = useState(null) const [payDialogInvoice, setPayDialogInvoice] = useState(null) const [payDialogOpen, setPayDialogOpen] = useState(false) @@ -64,7 +69,10 @@ export default function SalesInvoicesPage() { return () => clearTimeout(timeout) }, [searchInput]) - useEffect(() => setPage(1), [query, status, activeBranchId, customerId, warehouseId]) + useEffect(() => { + setPage(1) + setPageSize(PAGE_SIZE) + }, [query, status, activeBranchId, customerId, warehouseId]) useEffect(() => { customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) @@ -76,7 +84,7 @@ export default function SalesInvoicesPage() { salesApi .listInvoices({ page, - pageSize: PAGE_SIZE, + pageSize, status: status === "All" ? undefined : status, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, customerId, @@ -87,7 +95,13 @@ export default function SalesInvoicesPage() { setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) - }, [page, status, activeBranchId, branchingEnabled, customerId, warehouseId]) + .finally(() => setShowMoreLoading(false)) + }, [page, pageSize, status, activeBranchId, branchingEnabled, customerId, warehouseId]) + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } async function handleConfirm(row: SalesInvoiceSummary) { setConfirmingId(row.salesInvoiceId) @@ -137,17 +151,17 @@ export default function SalesInvoicesPage() {

Sales

Invoice register with filters, posting flow, and settlement tracking.

-
+
Print batch - + New Sale @@ -224,7 +238,112 @@ export default function SalesInvoicesPage() { {!error && rows !== null && visibleRows.length > 0 && ( <> -
+ {/* Mobile: stacked cards. */} +
+
+
+
Gross
+
{grossTotal.toFixed(2)}
+
+
+
Discount
+
-{discountTotal.toFixed(2)}
+
+
+
Net
+
{netTotal.toFixed(2)}
+
+
+ + {visibleRows.map((row) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+

{row.invoiceNo}

+

{row.customerSnapshotName}

+
+
+ + {row.status !== "Draft" && ( + + )} +
+
+ +
+
+

Date / Due

+

+ {new Date(row.createdAt).toLocaleDateString()} / {new Date(row.invoiceDate).toLocaleDateString()} +

+
+
+

Lines

+

{row.totals.freeQtyTotal.toFixed(2)}

+
+
+

Gross / Discount

+

{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}

+
+
+

Net

+

{row.totals.grandTotal.toFixed(2)}

+
+
+ + {(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && ( +
+ {row.status === "Draft" && ( + + )} + {row.totals.balanceAmount > 0 && ( + + )} +
+ )} +
+ ))} + + {pagination && rows !== null && rows.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
@@ -305,7 +424,7 @@ export default function SalesInvoicesPage() {
-
+
Gross total
@@ -323,7 +442,7 @@ export default function SalesInvoicesPage() {
{pagination && ( -
+
)} diff --git a/Frontend/erp-system/app/dashboard/sales/sales-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/sales-returns/new/page.tsx index dbb575e..138112b 100644 --- a/Frontend/erp-system/app/dashboard/sales/sales-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/sales-returns/new/page.tsx @@ -1,34 +1,10 @@ "use client" -import { Suspense, useEffect, useState } from "react" +import { Suspense } from "react" import { useRouter, useSearchParams } from "next/navigation" -import Link from "next/link" -import { salesReturnsApi } from "@/lib/api/sales-returns" -import { salesApi } from "@/lib/api/sales" -import { reasonCodesApi } from "@/lib/api/reason-codes" -import { itemsApi } from "@/lib/api/items" -import { errorMessage } from "@/lib/error-map" -import { validateSalesReturnLine } from "@/lib/validations/sales" -import { cn } from "@/lib/utils" -import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales" -import { ItemListItem } from "@/types/master-data" -import { ReasonCode } from "@/types/stock" - -import { Button, buttonVariants } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Checkbox } from "@/components/ui/checkbox" -import { FieldError } from "@/components/ui/field" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { SalesReturnForm } from "@/components/sales/SalesReturnForm" import { Skeleton } from "@/components/ui/skeleton" -import { toast } from "@/components/ui/toast" - -interface LineState { - selected: boolean - qty: string -} function NewSalesReturnContent() { const router = useRouter() @@ -36,143 +12,6 @@ function NewSalesReturnContent() { const presetInvoiceId = Number(searchParams.get("invoiceId")) || null const presetLineId = Number(searchParams.get("lineId")) || null - const [invoices, setInvoices] = useState(null) - const [items, setItems] = useState([]) - const [reasonCodes, setReasonCodes] = useState([]) - const [loadError, setLoadError] = useState(null) - const [remainingByLine, setRemainingByLine] = useState>({}) - const [remainingLoading, setRemainingLoading] = useState(false) - - const [invoiceId, setInvoiceId] = useState(presetInvoiceId) - const [reasonCodeId, setReasonCodeId] = useState(null) - const [lineState, setLineState] = useState>({}) - const [lineErrors, setLineErrors] = useState>>({}) - - const [headerError, setHeaderError] = useState(null) - const [submitError, setSubmitError] = useState(null) - const [submitting, setSubmitting] = useState(false) - - useEffect(() => { - Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")]) - .then(([invoiceList, it, rc]) => { - // Only Posted invoices have stock movements to return against. - Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data))) - setItems(it.items) - setReasonCodes(rc.items) - }) - .catch((err) => setLoadError(errorMessage(err))) - }, []) - - const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null - - useEffect(() => { - if (!selectedInvoice) { - setLineState({}) - setRemainingByLine({}) - return - } - const next: Record = {} - for (const line of selectedInvoice.lines) { - next[line.salesInvoiceLineId] = { selected: false, qty: "" } - } - setLineState(next) - setRemainingLoading(true) - salesReturnsApi - .getRemaining(selectedInvoice.salesInvoiceId) - .then((rows) => { - const map: Record = {} - for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty - setRemainingByLine(map) - if (presetLineId) { - const remaining = map[presetLineId] ?? 0 - setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } })) - } - }) - .catch((err) => setLoadError(errorMessage(err))) - .finally(() => setRemainingLoading(false)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedInvoice?.salesInvoiceId]) - - function itemFor(itemId: number) { - return items.find((i) => i.itemId === itemId) - } - - function remainingFor(lineId: number, fallback: number) { - return remainingByLine[lineId] ?? fallback - } - - function toggleLine(line: SalesInvoiceLine) { - const remaining = remainingFor(line.salesInvoiceLineId, line.qty) - setLineState((prev) => ({ - ...prev, - [line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) }, - })) - } - - function setQty(lineId: number, qty: string) { - setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } })) - } - - async function handleSubmit() { - setHeaderError(null) - setSubmitError(null) - - if (!selectedInvoice) { - setHeaderError("Select an invoice to return against.") - return - } - if (!reasonCodeId) { - setHeaderError("Select a reason code.") - return - } - - const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected) - if (selectedLines.length === 0) { - setSubmitError("Select at least one line to return.") - return - } - - const nextErrors: Record> = {} - for (const line of selectedLines) { - const errors = validateSalesReturnLine({ - salesInvoiceLineId: line.salesInvoiceLineId, - qty: lineState[line.salesInvoiceLineId].qty, - maxQty: remainingFor(line.salesInvoiceLineId, line.qty), - }) - if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors - } - setLineErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - setSubmitError("Fix the highlighted lines before submitting.") - return - } - - const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({ - salesInvoiceLineId: l.salesInvoiceLineId, - itemId: l.itemId, - qty: Number(lineState[l.salesInvoiceLineId].qty), - })) - - setSubmitting(true) - try { - const salesReturn = await salesReturnsApi.create({ - customerId: selectedInvoice.customerId, - warehouseId: selectedInvoice.warehouseId, - reasonCodeId, - lines: payloadLines, - }) - toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`) - router.push("/dashboard/sales/sales-returns") - } catch (err) { - setSubmitError(errorMessage(err)) - toast.error("Could not post sales return", errorMessage(err)) - } finally { - setSubmitting(false) - } - } - - const loading = !invoices - return (
@@ -182,118 +21,12 @@ function NewSalesReturnContent() {
- {loadError && ( -
{loadError}
- )} - - {loading && !loadError && } - - {!loading && ( - <> -
-
- - value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}> - - - - - {(invoices ?? []).map((inv) => ( - - {inv.invoiceNo} — {inv.customerSnapshotName} - - ))} - - -
-
- - value={reasonCodeId} onValueChange={setReasonCodeId}> - - - - - {reasonCodes.map((rc) => ( - - {rc.description} - - ))} - - -
-
- - {headerError && ( -
{headerError}
- )} - - {selectedInvoice && ( -
-

Lines invoiced on {selectedInvoice.invoiceNo}

- - - - - Item - Invoiced qty - Remaining qty - Unit price - Return qty - - - - {selectedInvoice.lines.map((line) => { - const item = itemFor(line.itemId) - const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" } - const errors = lineErrors[line.salesInvoiceLineId] ?? {} - const remaining = remainingFor(line.salesInvoiceLineId, line.qty) - const fullyReturned = !remainingLoading && remaining <= 0 - return ( - - - toggleLine(line)} /> - - {item ? `${item.sku} — ${item.name}` : line.description} - {line.qty} - - {remainingLoading ? "…" : fullyReturned ? Fully returned : remaining} - - {line.unitPrice.toFixed(2)} - - setQty(line.salesInvoiceLineId, e.target.value)} - className="h-11 text-base" - /> - - - - ) - })} - -
-
- )} - - {submitError && ( -
{submitError}
- )} - -
- - Cancel - - -
- - )} + router.push("/dashboard/sales/sales-returns")} + onSuccess={() => router.push("/dashboard/sales/sales-returns")} + />
) } diff --git a/Frontend/erp-system/app/dashboard/sales/sales-returns/page.tsx b/Frontend/erp-system/app/dashboard/sales/sales-returns/page.tsx index 33893fc..e094faf 100644 --- a/Frontend/erp-system/app/dashboard/sales/sales-returns/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/sales-returns/page.tsx @@ -9,32 +9,71 @@ import { customersApi } from "@/lib/api/customers" import { warehousesApi } from "@/lib/api/warehouses" import { reasonCodesApi } from "@/lib/api/reason-codes" import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" import { SalesReturnSummary } from "@/types/sales" import { Customer } from "@/types/customers" import { Warehouse } from "@/types/master-data" import { ReasonCode } from "@/types/stock" -import { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 + export default function SalesReturnsListPage() { const [returns, setReturns] = useState(null) const [customers, setCustomers] = useState([]) const [warehouses, setWarehouses] = useState([]) const [reasonCodes, setReasonCodes] = useState([]) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [showMoreLoading, setShowMoreLoading] = useState(false) + useEffect(() => { - Promise.all([salesReturnsApi.list(), customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) - .then(([r, c, w, rc]) => { - setReturns(r.items) + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query]) + + function load() { + setError(null) + salesReturnsApi + .list({ page, pageSize, q: query || undefined }) + .then((res) => { + setReturns(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(load, [page, pageSize, query]) + useEffect(() => { + Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) + .then(([c, w, rc]) => { setCustomers(c.items) setWarehouses(w.items) setReasonCodes(rc.items) }) - .catch((err) => setError(errorMessage(err))) + .catch(() => {}) }, []) function customerLabel(id: number) { @@ -48,6 +87,8 @@ export default function SalesReturnsListPage() { return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` } + const hasFilters = query.length > 0 + return (
@@ -55,20 +96,42 @@ export default function SalesReturnsListPage() {

Sales Returns

Return sold goods from a customer, referencing the original invoice line.

- + New Return
+
+ setSearchInput(e.target.value)} + placeholder="Search doc no., customer…" + className="h-12 w-full flex-1 basis-0 text-base" + aria-label="Search sales returns" + /> +
+ {error && (
{error}
)} {!error && returns === null && ( -
+
+
+ + +
{Array.from({ length: 4 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -76,47 +139,113 @@ export default function SalesReturnsListPage() { {!error && returns !== null && returns.length === 0 && (
-

No sales returns yet.

- - - New Return - +

{hasFilters ? "No sales returns match your search." : "No sales returns yet."}

+ {!hasFilters && ( + + + New Return + + )}
)} {!error && returns !== null && returns.length > 0 && ( - - - - Doc No - Customer - Warehouse - Reason - Return Qty - Status - GL Journal - Created - - - + <> + {/* Mobile: stacked cards. */} +
{returns.map((r) => ( - - {r.docNo} - {customerLabel(r.customerId)} - {warehouseCode(r.warehouseId)} - {reasonLabel(r.reasonCodeId)} - {r.totalQty != null ? r.totalQty.toFixed(0) : "—"} - - +
+
+
+

{r.docNo}

+

{customerLabel(r.customerId)}

+
+ {r.status} - - {r.glJournalNo ?? "—"} - {new Date(r.createdAt).toLocaleString()} - +
+ +
+
+

Warehouse

+

{warehouseCode(r.warehouseId)}

+
+
+

Reason

+

{reasonLabel(r.reasonCodeId)}

+
+
+

Return qty

+

{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}

+
+
+

GL Journal

+

{r.glJournalNo ?? "—"}

+
+
+

Created

+

{new Date(r.createdAt).toLocaleDateString()}

+
+
+
))} - -
+ + {pagination && returns.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Doc No + Customer + Warehouse + Reason + Return Qty + Status + GL Journal + Created + + + + {returns.map((r) => ( + + {r.docNo} + {customerLabel(r.customerId)} + {warehouseCode(r.warehouseId)} + {reasonLabel(r.reasonCodeId)} + {r.totalQty != null ? r.totalQty.toFixed(0) : "—"} + + + {r.status} + + + {r.glJournalNo ?? "—"} + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} + )}
) diff --git a/Frontend/erp-system/app/dashboard/settings/branch-configuration/page.tsx b/Frontend/erp-system/app/dashboard/settings/branch-configuration/page.tsx index 60c7149..38dbaed 100644 --- a/Frontend/erp-system/app/dashboard/settings/branch-configuration/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/branch-configuration/page.tsx @@ -86,7 +86,7 @@ export default function BranchConfigurationPage() { {!error && !config && } {!error && config && ( -
+

Branches

diff --git a/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx b/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx index 2787c5d..a89e0b7 100644 --- a/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/company-profile/page.tsx @@ -160,7 +160,7 @@ export default function CompanyProfilePage() { {!error && !profile && } {!error && profile && ( -
+

Identity & contact

@@ -272,14 +272,14 @@ export default function CompanyProfilePage() { )}
-
+
{profile.updatedAt && (

Last changed {new Date(profile.updatedAt).toLocaleString()} {profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}.

)} -
diff --git a/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx b/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx index 9ae997f..665493e 100644 --- a/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx @@ -106,7 +106,7 @@ export default function InvoiceTemplatePage() { {!error && config && ( <> -
+

Layout

@@ -172,20 +172,20 @@ export default function InvoiceTemplatePage() { -
+
{config.updatedAt && (

Last changed {new Date(config.updatedAt).toLocaleString()} {config.updatedBy ? ` by user #${config.updatedBy}` : ""}.

)} -
-
+
Live preview {(() => { const layout = getInvoiceLayoutClasses(templateStyle) @@ -199,7 +199,7 @@ export default function InvoiceTemplatePage() { const companyBlock = return ( -
+
{layout.arrangement === "compact" ? (
{titleBlock} @@ -225,7 +225,7 @@ export default function InvoiceTemplatePage() {
Net payable: 12,450.00
) : ( -
+
Customer
Sample Customer Ltd
@@ -243,22 +243,24 @@ export default function InvoiceTemplatePage() { )}
- - - - - - - - - - - - - - - -
ItemQtyLine total
Sample product2.0012,450.00
+
+ + + + + + + + + + + + + + + +
ItemQtyLine total
Sample product2.0012,450.00
+
diff --git a/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx index aedcecb..a18b041 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx @@ -164,28 +164,26 @@ export default function RoleDetailPage() { return (
-
-
-
-
-

{role.code}

- - {role.status} - -
-

{role.name}

+
+
+
+

{role.code}

+ + {role.status} +
+

{role.name}

- diff --git a/Frontend/erp-system/app/dashboard/settings/roles/new/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/new/page.tsx index 58f3e00..c8f4ea9 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/new/page.tsx @@ -179,16 +179,16 @@ export default function NewRolePage() { {!allActionsSet && (

Every selected section needs at least one action before you can create the role.

)} -
- -
- +
+ Cancel - diff --git a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx index 219303e..42d2875 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx @@ -30,6 +30,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" const PAGE_SIZE = 10 +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function RolesPage() { const router = useRouter() @@ -37,21 +40,29 @@ export default function RolesPage() { const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE) const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) function load() { rolesApi - .list({ page, pageSize: PAGE_SIZE, q: debouncedSearch || undefined }) + .list({ page, pageSize, q: debouncedSearch || undefined }) .then((res) => { setRoles(res.items) setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) } - useEffect(load, [page, debouncedSearch]) + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(load, [page, pageSize, debouncedSearch]) useEffect(() => { const t = setTimeout(() => { @@ -170,116 +181,208 @@ export default function RolesPage() { )} {!error && roles !== null && roles.length > 0 && ( -
-
- - - - Role - Code - Status - Created Date - Actions - - - - {roles.map((r) => ( - { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/settings/roles/${r.roleId}`) - }} - > - - -
- -
-
- {r.name} - {r.isSystemRole && ( - - System - - )} -
- -
- - + <> + {/* Mobile: stacked cards. */} +
+ {roles.map((r) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/settings/roles/${r.roleId}`) + }} + onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/settings/roles/${r.roleId}`) }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+
+ +
+
+ {r.name} {r.code} - +
+
+ + {r.status} + +
- - - {r.status} +
+
+ + {new Date(r.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })} + + {r.isSystemRole && ( + + System - + )} +
+
+ + + + + + } + > + + + handleDelete(r)} + /> + +
+
+
+ ))} - - {new Date(r.createdAt).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - })} - + {pagination && roles.length < pagination.totalItems && ( + + )} +
- -
+ {/* Desktop: table. */} +
+
+
+ + + Role + Code + Status + Created Date + Actions + + + + {roles.map((r) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/settings/roles/${r.roleId}`) + }} + > + - +
+ +
+
+ {r.name} + {r.isSystemRole && ( + + System + + )} +
+
- - - } + + {r.code} + + + + + {r.status} + + + + + {new Date(r.createdAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + + +
+ - - - handleDelete(r)} - /> - -
-
-
- ))} -
-
+ + + + + + } + > + + + handleDelete(r)} + /> + +
+ + + ))} + + +
-
+ )} {pagination && pagination.totalPages > 1 && ( -
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx index 755d8bf..1db25a1 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx @@ -95,9 +95,9 @@ export default function UserDetailPage() { return (

-
+
-
+

{user.username}

0 && (
    {activity.map((a) => ( -
  • -
    +
  • +
    0 && (
      {devices.map((d) => ( -
    • -
      +
    • +
      {d.deviceLabel ?? "Unknown device"} {d.ipAddress && {d.ipAddress}} diff --git a/Frontend/erp-system/app/dashboard/settings/users/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/page.tsx index e7c3d7d..fc057ec 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/page.tsx @@ -31,6 +31,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function UsersPage() { const router = useRouter() @@ -43,6 +46,7 @@ export default function UsersPage() { const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) function load() { usersApi @@ -52,6 +56,12 @@ export default function UsersPage() { setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } useEffect(load, [page, pageSize, debouncedSearch]) @@ -190,87 +200,159 @@ export default function UsersPage() { )} {!error && users !== null && users.length > 0 && ( -
      -
      - - - - Username - Display name - Role - Status - Actions - - - - {users.map((u) => ( - { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/settings/users/${u.userId}`) - }} + <> + {/* Mobile: stacked cards. */} +
      + {users.map((u) => ( +
      { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/settings/users/${u.userId}`) + }} + onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/settings/users/${u.userId}`) }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
      +
      +

      {u.username}

      +

      {u.displayName}

      +
      + - - - {u.username} - - - {u.displayName} - {u.roleName ?? "—"} - - - {u.status} - - - -
      + {u.status} + +
      + +
      + {u.roleName ?? "—"} +
      + + + + +
      +
      +
      + ))} + + {pagination && users.length < pagination.totalItems && ( + + )} +
      + + {/* Desktop: table. */} +
      +
      +
      + + + Username + Display name + Role + Status + Actions + + + + {users.map((u) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/settings/users/${u.userId}`) + }} + > + - + {u.username} - - - - - ))} - -
      + {u.status} + + + +
      + + + + +
      +
      + + ))} + + +
      -
      + )} {pagination && pagination.totalItems > 0 && ( -
      +

      Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx index 148f75f..2e5f5d2 100644 --- a/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/adjustments/new/page.tsx @@ -244,7 +244,7 @@ export default function NewAdjustmentPage() {

      -
      +

      Lines

      - - - - Item - Qty change - - - - - {lines.map((line) => ( - - + {/* Mobile: stacked cards. */} +
      + {lines.map((line) => ( +
      +
      +
      + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> @@ -276,39 +271,90 @@ export default function NewAdjustmentPage() { ))} - - - updateLine(line.key, { qtyDelta: e.target.value })} - className="h-11 text-base" - /> - - - - - +
      + +
      + +
      + + updateLine(line.key, { qtyDelta: e.target.value })} + className="h-11 text-base" + /> + +
      +
      + ))} +
      + + {/* Desktop: table. */} +
      +
      + + + Item + Qty change + - ))} - -
      + + + {lines.map((line) => ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + updateLine(line.key, { qtyDelta: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ))} + + +
      {submitError && (
      {submitError}
      )} -
      - +
      + Cancel -
      diff --git a/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx b/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx index 12e5861..4718689 100644 --- a/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx @@ -2,45 +2,80 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { Plus, SlidersHorizontal } from "lucide-react" +import { Plus, Search, SlidersHorizontal } from "lucide-react" import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" import { warehousesApi } from "@/lib/api/warehouses" import { reasonCodesApi } from "@/lib/api/reason-codes" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" import { StockAdjustmentSummary } from "@/types/stock" import { Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { AdjustmentStatusBadge } from "@/components/stock/status-badges" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 + export default function AdjustmentsListPage() { const { activeBranchId, branchingEnabled } = useAuth() const [adjustments, setAdjustments] = useState(null) const [warehouses, setWarehouses] = useState(null) const [reasonLabels, setReasonLabels] = useState>(new Map()) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [showMoreLoading, setShowMoreLoading] = useState(false) + useEffect(() => { - Promise.all([ - stockAdjustmentsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), - warehousesApi.list(), - reasonCodesApi.list("Adjustment"), - ]) - .then(([a, wh, rc]) => { - setAdjustments(a.items) + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query]) + + useEffect(() => { + setError(null) + stockAdjustmentsApi + .list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }) + .then((res) => { + setAdjustments(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + }, [page, pageSize, query, activeBranchId, branchingEnabled]) + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(() => { + Promise.all([warehousesApi.list(), reasonCodesApi.list("Adjustment")]) + .then(([wh, rc]) => { setWarehouses(wh.items) setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description]))) }) - .catch((err) => setError(errorMessage(err))) - }, [activeBranchId, branchingEnabled]) + .catch(() => {}) + }, []) const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` + const hasFilters = query.length > 0 return (
      @@ -51,20 +86,45 @@ export default function AdjustmentsListPage() {

      Increase, decrease, or write off stock with a reason code (FR-STK-07).

      - + New Adjustment
      +
      +
      + + setSearchInput(e.target.value)} + placeholder="Search doc no…" + className="h-12 w-full pl-11 text-base" + aria-label="Search stock adjustments" + /> +
      +
      + {error && (
      {error}
      )} {!error && adjustments === null && ( -
      +
      +
      + + +
      {Array.from({ length: 4 }).map((_, i) => ( - +
      + +
      + + +
      + + +
      ))}
      )} @@ -72,41 +132,98 @@ export default function AdjustmentsListPage() { {!error && adjustments !== null && adjustments.length === 0 && (
      -

      No adjustments yet.

      - - - New Adjustment - +

      {hasFilters ? "No adjustments match your search." : "No adjustments yet."}

      + {!hasFilters && ( + + + New Adjustment + + )}
      )} {!error && adjustments !== null && adjustments.length > 0 && ( - - - - Doc No - Warehouse - Reason - Status - GL Journal - Created - - - + <> + {/* Mobile: stacked cards. */} +
      {adjustments.map((a) => ( - - {a.docNo} - {whCode(a.warehouseId)} - {reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`} - +
      +
      +

      {a.docNo}

      - - {a.glJournalNo ?? "—"} - {new Date(a.createdAt).toLocaleString()} - +
      + +
      +
      +

      Warehouse

      +

      {whCode(a.warehouseId)}

      +
      +
      +

      Reason

      +

      {reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}

      +
      +
      +

      GL Journal

      +

      {a.glJournalNo ?? "—"}

      +
      +
      +

      Created

      +

      {new Date(a.createdAt).toLocaleDateString()}

      +
      +
      +
      ))} - -
      + + {pagination && adjustments.length < pagination.totalItems && ( + + )} +
      + + {/* Desktop: table. */} +
      +
      + + + + Doc No + Warehouse + Reason + Status + GL Journal + Created + + + + {adjustments.map((a) => ( + + {a.docNo} + {whCode(a.warehouseId)} + {reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`} + + + + {a.glJournalNo ?? "—"} + {new Date(a.createdAt).toLocaleString()} + + ))} + +
      +
      +
      + + {pagination && ( +
      + { setPage(1); setPageSize(size) }} + /> +
      + )} + )}
      ) diff --git a/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx index 5b31feb..52e4bac 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx @@ -139,14 +139,15 @@ export default function CountDetailPage() {
    {count.status !== "Posted" && ( -
    -
    -
    ) } diff --git a/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx index ede4faa..f667f86 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/new/page.tsx @@ -194,11 +194,11 @@ export default function NewCountPage() {
    {submitError}
    )} -
    - +
    + Cancel -
    diff --git a/Frontend/erp-system/app/dashboard/stock/counts/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/page.tsx index 7ffb20c..e3a3708 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/page.tsx @@ -2,44 +2,78 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ClipboardList, Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { ClipboardList, Eye, Plus, Search } from "lucide-react" import { stockCountsApi } from "@/lib/api/stock-counts" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" import { StockCountSummary } from "@/types/stock" import { Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" import { useDashboardStats } from "@/hooks/use-dashboard-stats" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { KpiRow } from "@/components/ui/kpi-row" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { CountStatusBadge } from "@/components/stock/status-badges" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 + export default function CountsListPage() { + const router = useRouter() const { activeBranchId, branchingEnabled } = useAuth() const stats = useDashboardStats() const [counts, setCounts] = useState(null) const [warehouses, setWarehouses] = useState(null) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [showMoreLoading, setShowMoreLoading] = useState(false) + useEffect(() => { - Promise.all([ - stockCountsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), - warehousesApi.list(), - ]) - .then(([c, wh]) => { - setCounts(c.items) - setWarehouses(wh.items) + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query]) + + useEffect(() => { + setError(null) + stockCountsApi + .list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }) + .then((res) => { + setCounts(res.items) + setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) - }, [activeBranchId, branchingEnabled]) + .finally(() => setShowMoreLoading(false)) + }, [page, pageSize, query, activeBranchId, branchingEnabled]) + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(() => { + warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([])) + }, []) const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` + const hasFilters = query.length > 0 return (
    @@ -50,7 +84,7 @@ export default function CountsListPage() {

    Cycle or full physical counts (FR-STK-08).

    - + New Count @@ -58,14 +92,39 @@ export default function CountsListPage() { +
    +
    + + setSearchInput(e.target.value)} + placeholder="Search doc no…" + className="h-12 w-full pl-11 text-base" + aria-label="Search stock counts" + /> +
    +
    + {error && (
    {error}
    )} {!error && counts === null && ( -
    +
    +
    + + +
    {Array.from({ length: 4 }).map((_, i) => ( - +
    + +
    + + +
    + + +
    ))}
    )} @@ -73,43 +132,128 @@ export default function CountsListPage() { {!error && counts !== null && counts.length === 0 && (
    -

    No counts yet.

    - - - New Count - +

    {hasFilters ? "No counts match your search." : "No counts yet."}

    + {!hasFilters && ( + + + New Count + + )}
    )} {!error && counts !== null && counts.length > 0 && ( - - - - Doc No - Warehouse - Type - Status - Created - - - + <> + {/* Mobile: stacked cards. */} +
    {counts.map((c) => ( - - - - {c.docNo} - - - {whCode(c.warehouseId)} - {c.countType} - +
    { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/stock/counts/${c.countId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/stock/counts/${c.countId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
    +

    {c.docNo}

    - - {new Date(c.createdAt).toLocaleString()} - +
    + +
    +
    +

    Warehouse

    +

    {whCode(c.warehouseId)}

    +
    +
    +

    Type

    +

    {c.countType}

    +
    +
    +

    Created

    +

    {new Date(c.createdAt).toLocaleDateString()}

    +
    +
    +
    ))} - -
    + + {pagination && counts.length < pagination.totalItems && ( + + )} +
    + + {/* Desktop: table. */} +
    +
    + + + + Doc No + Warehouse + Type + Status + Created + Actions + + + + {counts.map((c) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/stock/counts/${c.countId}`) + }} + > + + + {c.docNo} + + + {whCode(c.warehouseId)} + {c.countType} + + + + {new Date(c.createdAt).toLocaleString()} + +
    + + + +
    +
    +
    + ))} +
    +
    +
    +
    + + {pagination && ( +
    + { setPage(1); setPageSize(size) }} + /> +
    + )} + )}
) diff --git a/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx b/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx index 74dd2b3..7eb56f6 100644 --- a/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/enquiry/page.tsx @@ -2,21 +2,29 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { PackageSearch, Search } from "lucide-react" +import { Eye, PackageSearch, Search } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" import { OnHand } from "@/types/stock" import { ItemListItem, Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" +import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size, and +// since these rows are already loaded client-side, more just slice in immediately. +const MOBILE_PAGE_SIZE_STEP = 10 + export default function StockEnquiryPage() { const { activeBranchId, branchingEnabled } = useAuth() @@ -27,6 +35,8 @@ export default function StockEnquiryPage() { const [search, setSearch] = useState("") const [warehouseId, setWarehouseId] = useState("All") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) useEffect(() => { Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) @@ -56,6 +66,18 @@ export default function StockEnquiryPage() { .sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId) }, [rows, search, warehouseId, itemsById, warehousesById, activeBranchId, branchingEnabled]) + useEffect(() => setPage(1), [search, warehouseId]) + + const pagination = filtered + ? { + page, + pageSize, + totalItems: filtered.length, + totalPages: Math.max(1, Math.ceil(filtered.length / pageSize)), + } + : null + const pagedRows = filtered?.slice((page - 1) * pageSize, page * pageSize) ?? null + const loading = rows === null || items === null || warehouses === null return ( @@ -100,9 +122,21 @@ export default function StockEnquiryPage() { )} {loading && !error && ( -
+
+
+ + +
{Array.from({ length: 4 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -114,49 +148,126 @@ export default function StockEnquiryPage() {
)} - {!loading && !error && filtered !== null && filtered.length > 0 && ( - - - - Item - Warehouse - On hand - Available - On hold - In transit - Reserved - Valuation - - - - {filtered.map((row) => { + {!loading && !error && pagedRows !== null && pagedRows.length > 0 && ( + <> + {/* Mobile: stacked cards, "Show more" instead of pagination. */} +
+ {filtered!.slice(0, page * pageSize).map((row) => { const item = itemsById.get(row.itemId) const wh = warehousesById.get(row.warehouseId) return ( - - -
{item?.sku ?? `#${row.itemId}`}
-
{item?.name}
-
- {wh?.code ?? `#${row.warehouseId}`} - {row.onHand} - {row.available} - {row.onHold} - {row.inTransit} - {row.reserved} - - - View - - -
+ +
+

{item?.sku ?? `#${row.itemId}`}

+

{item?.name}

+

{wh?.code ?? `#${row.warehouseId}`}

+
+ +
+
+

On hand

+

{row.onHand}

+
+
+

Available

+

{row.available}

+
+
+

On hold

+

{row.onHold}

+
+
+

In transit

+

{row.inTransit}

+
+
+

Reserved

+

{row.reserved}

+
+
+ ) })} - -
+ + {filtered && page * pageSize < filtered.length && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + SKU + Item name + Warehouse + On hand + Available + On hold + In transit + Reserved + Actions + + + + {pagedRows.map((row) => { + const item = itemsById.get(row.itemId) + const wh = warehousesById.get(row.warehouseId) + return ( + + {item?.sku ?? `#${row.itemId}`} + {item?.name} + {wh?.code ?? `#${row.warehouseId}`} + {row.onHand} + {row.available} + {row.onHold} + {row.inTransit} + {row.reserved} + +
+ + + +
+
+
+ ) + })} +
+
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} + )}
) diff --git a/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx b/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx index 62a23e7..b1a4568 100644 --- a/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx @@ -13,6 +13,7 @@ import { PaginationMeta } from "@/types/common" import { ItemListItem, Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" +import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { PaginationFooter } from "@/components/ui/pagination-footer" @@ -21,7 +22,10 @@ import { Badge } from "@/components/ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -const PAGE_SIZE = 25 +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function StockLedgerPage() { const { activeBranchId, branchingEnabled } = useAuth() @@ -37,6 +41,8 @@ export default function StockLedgerPage() { const [from, setFrom] = useState("") const [to, setTo] = useState("") const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [showMoreLoading, setShowMoreLoading] = useState(false) useEffect(() => { Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) @@ -61,7 +67,7 @@ export default function StockLedgerPage() { from: from || undefined, to: to || undefined, page, - pageSize: PAGE_SIZE, + pageSize, }) .then((res) => { if (cancelled) return @@ -71,10 +77,18 @@ export default function StockLedgerPage() { .catch((err) => { if (!cancelled) setError(errorMessage(err)) }) + .finally(() => { + if (!cancelled) setShowMoreLoading(false) + }) return () => { cancelled = true } - }, [itemId, warehouseId, from, to, page, activeBranchId, branchingEnabled]) + }, [itemId, warehouseId, from, to, page, pageSize, activeBranchId, branchingEnabled]) + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) @@ -141,9 +155,21 @@ export default function StockLedgerPage() {
{loading && !error && ( -
+
+
+ + +
{Array.from({ length: 5 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -157,56 +183,128 @@ export default function StockLedgerPage() { {!loading && !error && entries !== null && entries.length > 0 && ( <> - - - - Date - Item - Warehouse - Direction - Qty - Unit cost - Value - Running balance - Source - - - - {entries.map((entry) => { - const item = itemsById.get(entry.itemId) - const wh = warehousesById.get(entry.warehouseId) - return ( - - {new Date(entry.createdAt).toLocaleString()} - {item?.sku ?? `#${entry.itemId}`} - {wh?.code ?? `#${entry.warehouseId}`} - - - {entry.direction} - - - {entry.qtyBase} - {entry.unitCost.toFixed(2)} - {entry.value.toFixed(2)} - {entry.runningBalance} - - {entry.sourceDocType} #{entry.sourceDocId} - - - ) - })} - -
+ {/* Mobile: stacked cards. */} +
+ {entries.map((entry) => { + const item = itemsById.get(entry.itemId) + const wh = warehousesById.get(entry.warehouseId) + return ( +
+
+
+

{item?.sku ?? `#${entry.itemId}`}

+

{wh?.code ?? `#${entry.warehouseId}`}

+
+ + {entry.direction} + +
- {pagination && } +
+
+

Qty / Unit cost

+

{entry.qtyBase} @ {entry.unitCost.toFixed(2)}

+
+
+

Value

+

{entry.value.toFixed(2)}

+
+
+

Running balance

+

{entry.runningBalance}

+
+
+

Source

+

{entry.sourceDocType} #{entry.sourceDocId}

+
+
+

Date

+

{new Date(entry.createdAt).toLocaleString()}

+
+
+
+ ) + })} + + {pagination && entries.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Date + Item + Warehouse + Direction + Qty + Unit cost + Value + Running balance + Source + + + + {entries.map((entry) => { + const item = itemsById.get(entry.itemId) + const wh = warehousesById.get(entry.warehouseId) + return ( + + {new Date(entry.createdAt).toLocaleString()} + {item?.sku ?? `#${entry.itemId}`} + {wh?.code ?? `#${entry.warehouseId}`} + + + {entry.direction} + + + {entry.qtyBase} + {entry.unitCost.toFixed(2)} + {entry.value.toFixed(2)} + {entry.runningBalance} + + {entry.sourceDocType} #{entry.sourceDocId} + + + ) + })} + +
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} )}
diff --git a/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx b/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx index 31892e0..f0ad65e 100644 --- a/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/reorder-alerts/page.tsx @@ -95,54 +95,108 @@ export default function ReorderAlertsPage() { )} {!loading && !error && alerts.length > 0 && ( - - - - Item - Warehouse - Available - Reorder point - Suggested qty - Action - - - + <> + {/* Mobile: stacked cards. */} +
{alerts.map((alert) => { const key = `${alert.itemId}-${alert.warehouseId}` const item = itemsById.get(alert.itemId) const wh = warehousesById.get(alert.warehouseId) const done = requested.has(key) return ( - - -
- -
-
{item?.sku ?? `#${alert.itemId}`}
-
{item?.name}
-
+
+
+ +
+

{item?.sku ?? `#${alert.itemId}`}

+

{item?.name}

- - {wh?.code ?? `#${alert.warehouseId}`} - {alert.available} - {alert.reorderPoint} - {alert.suggestedRequisitionQty} - - - - +
+ +
+
+

Available

+

{alert.available}

+
+
+

Reorder pt

+

{alert.reorderPoint}

+
+
+

Suggested

+

{alert.suggestedRequisitionQty}

+
+
+ +

{wh?.code ?? `#${alert.warehouseId}`}

+ + +
) })} - -
+
+ + {/* Desktop: table. */} +
+ + + + Item + Warehouse + Available + Reorder point + Suggested qty + Action + + + + {alerts.map((alert) => { + const key = `${alert.itemId}-${alert.warehouseId}` + const item = itemsById.get(alert.itemId) + const wh = warehousesById.get(alert.warehouseId) + const done = requested.has(key) + return ( + + +
+ +
+
{item?.sku ?? `#${alert.itemId}`}
+
{item?.name}
+
+
+
+ {wh?.code ?? `#${alert.warehouseId}`} + {alert.available} + {alert.reorderPoint} + {alert.suggestedRequisitionQty} + + + +
+ ) + })} +
+
+
+ )}
) diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx index f6c5835..da07c7d 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx @@ -5,8 +5,10 @@ import { useParams } from "next/navigation" import { CheckCircle2, PackageCheck, Truck } from "lucide-react" import { stockTransfersApi } from "@/lib/api/stock-transfers" +import { itemsApi } from "@/lib/api/items" import { errorMessage } from "@/lib/error-map" import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock" +import { ItemListItem } from "@/types/master-data" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -19,6 +21,7 @@ export default function TransferDetailPage() { const transferId = Number(params.id) const [transfer, setTransfer] = useState(null) + const [items, setItems] = useState([]) const [error, setError] = useState(null) const [dispatching, setDispatching] = useState(false) const [receiving, setReceiving] = useState(false) @@ -30,6 +33,15 @@ export default function TransferDetailPage() { stockTransfersApi.get(transferId).then(setTransfer).catch((err) => setError(errorMessage(err))) }, [transferId]) + useEffect(() => { + itemsApi.list({ pageSize: 200 }).then((res) => setItems(res.items)).catch(() => {}) + }, []) + + function itemName(itemId: number) { + const item = items.find((i) => i.itemId === itemId) + return item ? `${item.sku} — ${item.name}` : `#${itemId}` + } + async function handleDispatch() { setError(null) setDispatching(true) @@ -94,15 +106,15 @@ export default function TransferDetailPage() {
-
+
{transfer.status === "Draft" && ( - )} {transfer.status === "InTransit" && ( - @@ -140,26 +152,54 @@ export default function TransferDetailPage() {
)} - - - - Item - Src bin - Dest bin - Qty - - - - {transfer.lines.map((line) => ( - - {line.itemId} - {line.srcBinId ?? "—"} - {line.destBinId ?? "—"} - {line.qty} - - ))} - -
+ {/* Mobile: stacked cards. */} +
+ {transfer.lines.map((line) => ( +
+

{itemName(line.itemId)}

+
+
+

Src bin

+

{line.srcBinId ?? "—"}

+
+
+

Dest bin

+

{line.destBinId ?? "—"}

+
+
+

Qty

+

{line.qty}

+
+
+
+ ))} +
+ + {/* Desktop: table. */} +
+
+ + + + Item + Src bin + Dest bin + Qty + + + + {transfer.lines.map((line) => ( + + {itemName(line.itemId)} + {line.srcBinId ?? "—"} + {line.destBinId ?? "—"} + {line.qty} + + ))} + +
+
+
) } diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx index 2a76ce5..d8764e4 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { Plus, Trash2 } from "lucide-react" @@ -8,10 +8,12 @@ import { Plus, Trash2 } from "lucide-react" import { stockTransfersApi } from "@/lib/api/stock-transfers" import { warehousesApi } from "@/lib/api/warehouses" import { itemsApi } from "@/lib/api/items" +import { branchesHrmApi } from "@/lib/api/hrm-masters" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { CreateTransferLineInput } from "@/types/stock" import { Bin, ItemListItem, Warehouse } from "@/types/master-data" +import { Branch } from "@/types/hrm" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -44,10 +46,12 @@ export default function NewTransferPage() { const [warehouses, setWarehouses] = useState(null) const [items, setItems] = useState(null) + const [branches, setBranches] = useState([]) const [srcBins, setSrcBins] = useState([]) const [destBins, setDestBins] = useState([]) const [loadError, setLoadError] = useState(null) + const [branchId, setBranchId] = useState("All") const [srcWarehouseId, setSrcWarehouseId] = useState(null) const [destWarehouseId, setDestWarehouseId] = useState(null) const [srcBinId, setSrcBinId] = useState(null) @@ -66,6 +70,26 @@ export default function NewTransferPage() { .catch((err) => setLoadError(errorMessage(err))) }, []) + useEffect(() => { + branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([])) + }, []) + + const visibleWarehouses = useMemo(() => { + const all = warehouses ?? [] + if (branchId === "All") return all + const matches = all.filter((w) => w.branchId === branchId) + // Fail open: if no warehouse is actually tagged with this branch (branch scoping isn't + // wired up for warehouses in every tenant), don't leave the picker with nothing to select. + return matches.length > 0 ? matches : all + }, [warehouses, branchId]) + + // Switching the branch filter can strand a selected warehouse outside the new list. + useEffect(() => { + if (srcWarehouseId && !visibleWarehouses.some((w) => w.warehouseId === srcWarehouseId)) setSrcWarehouseId(null) + if (destWarehouseId && !visibleWarehouses.some((w) => w.warehouseId === destWarehouseId)) setDestWarehouseId(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visibleWarehouses]) + useEffect(() => { if (!srcWarehouseId) { setSrcBins([]) @@ -93,6 +117,10 @@ export default function NewTransferPage() { async function handleSubmit() { setSubmitError(null) + if (branchId === "All") { + setSubmitError("Select a branch for this transfer.") + return + } if (!srcWarehouseId || !destWarehouseId) { setSubmitError("Select both a source and destination warehouse.") return @@ -122,7 +150,7 @@ export default function NewTransferPage() { setSubmitting(true) try { - const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, lines: payloadLines }) + const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, branchId, lines: payloadLines }) toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`) router.push(`/dashboard/stock/transfers/${transfer.transferId}`) } catch (err) { @@ -152,6 +180,27 @@ export default function NewTransferPage() { {!loading && ( <> +
+ + + value={branchId} + onValueChange={(v) => setBranchId(v ?? "All")} + > + + + + + All branches + {branches.map((b) => ( + + {b.name} + + ))} + + +

Required — the branch this transfer belongs to. Also narrows the warehouse choices below.

+
+
@@ -160,7 +209,7 @@ export default function NewTransferPage() { - {(warehouses ?? []).map((w) => ( + {visibleWarehouses.map((w) => ( {w.code} — {w.name} @@ -192,7 +241,7 @@ export default function NewTransferPage() { - {(warehouses ?? []) + {visibleWarehouses .filter((w) => w.warehouseId !== srcWarehouseId) .map((w) => ( @@ -205,7 +254,7 @@ export default function NewTransferPage() {
-
+

Lines

- - - - Item - Destination bin - Qty - - - - - {lines.map((line) => ( - - + {/* Mobile: stacked cards. */} +
+ {lines.map((line) => ( +
+
+
+ value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> @@ -238,8 +281,15 @@ export default function NewTransferPage() { ))} - - +
+ +
+ +
+
+ value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}> @@ -252,39 +302,101 @@ export default function NewTransferPage() { ))} - - +
+
+ updateLine(line.key, { qty: e.target.value })} className="h-11 text-base" /> - - - - +
+
+
+ ))} +
+ + {/* Desktop: table. */} +
+
+ + + Item + Destination bin + Qty + - ))} - -
+ + + {lines.map((line) => ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}> + + + + + {destBins.map((b) => ( + + {b.code} + + ))} + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ))} + + +
{submitError && (
{submitError}
)} -
- +
+ Cancel -
diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx index d709d18..e65210f 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx @@ -2,41 +2,75 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeftRight, Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { ArrowLeftRight, Eye, Plus, Search } from "lucide-react" import { stockTransfersApi } from "@/lib/api/stock-transfers" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" import { StockTransferSummary } from "@/types/stock" import { Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { TransferStatusBadge } from "@/components/stock/status-badges" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 + export default function TransfersListPage() { + const router = useRouter() const { activeBranchId, branchingEnabled } = useAuth() const [transfers, setTransfers] = useState(null) const [warehouses, setWarehouses] = useState(null) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) + const [showMoreLoading, setShowMoreLoading] = useState(false) + useEffect(() => { - Promise.all([ - stockTransfersApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), - warehousesApi.list(), - ]) - .then(([t, wh]) => { - setTransfers(t.items) - setWarehouses(wh.items) + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query]) + + useEffect(() => { + setError(null) + stockTransfersApi + .list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }) + .then((res) => { + setTransfers(res.items) + setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) - }, [activeBranchId, branchingEnabled]) + .finally(() => setShowMoreLoading(false)) + }, [page, pageSize, query, activeBranchId, branchingEnabled]) + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(() => { + warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([])) + }, []) const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` + const hasFilters = query.length > 0 return (
@@ -47,20 +81,45 @@ export default function TransfersListPage() {

Move stock between warehouses (FR-STK-05/06).

- + New Transfer
+
+
+ + setSearchInput(e.target.value)} + placeholder="Search doc no…" + className="h-12 w-full pl-11 text-base" + aria-label="Search stock transfers" + /> +
+
+ {error && (
{error}
)} {!error && transfers === null && ( -
+
+
+ + +
{Array.from({ length: 4 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -68,43 +127,128 @@ export default function TransfersListPage() { {!error && transfers !== null && transfers.length === 0 && (
-

No transfers yet.

- - - New Transfer - +

{hasFilters ? "No transfers match your search." : "No transfers yet."}

+ {!hasFilters && ( + + + New Transfer + + )}
)} {!error && transfers !== null && transfers.length > 0 && ( - - - - Doc No - From - To - Status - Created - - - + <> + {/* Mobile: stacked cards. */} +
{transfers.map((t) => ( - - - - {t.docNo} - - - {whCode(t.srcWarehouseId)} - {whCode(t.destWarehouseId)} - +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/stock/transfers/${t.transferId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/stock/transfers/${t.transferId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+

{t.docNo}

- - {new Date(t.createdAt).toLocaleString()} - +
+ +
+
+

From

+

{whCode(t.srcWarehouseId)}

+
+
+

To

+

{whCode(t.destWarehouseId)}

+
+
+

Created

+

{new Date(t.createdAt).toLocaleDateString()}

+
+
+
))} - -
+ + {pagination && transfers.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Doc No + From + To + Status + Created + Actions + + + + {transfers.map((t) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/stock/transfers/${t.transferId}`) + }} + > + + + {t.docNo} + + + {whCode(t.srcWarehouseId)} + {whCode(t.destWarehouseId)} + + + + {new Date(t.createdAt).toLocaleString()} + +
+ + + +
+
+
+ ))} +
+
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} + )}
) diff --git a/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx b/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx index 6bcea72..525e42d 100644 --- a/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/valuation/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useEffect, useMemo, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" -import { BadgeDollarSign } from "lucide-react" +import { BadgeDollarSign, Boxes, Layers } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" @@ -19,6 +19,21 @@ import { Skeleton } from "@/components/ui/skeleton" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +/** Small tinted stat chip — value + label + icon. Matches the vendor/customer profile pages. */ +function StatChip({ icon: Icon, label, value }: { icon: typeof Boxes; label: string; value: string }) { + return ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ) +} + function ValuationContent() { const router = useRouter() const searchParams = useSearchParams() @@ -53,6 +68,17 @@ function ValuationContent() { .catch((err) => setError(errorMessage(err))) }, [itemId, warehouseId]) + // Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the + // narrow card instead of leaving blank trailing slots — there's less width to spare there. + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const mql = window.matchMedia("(max-width: 639px)") + setIsMobile(mql.matches) + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, []) + const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId]) const visibleWarehouses = useMemo( () => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])), @@ -126,19 +152,10 @@ function ValuationContent() { ) : ( <> -
-
-

Item

-

{item?.sku ?? `#${itemId}`}

-
-
-

Total quantity

-

{valuation.totalQty}

-
-
-

Total value ({valuation.currency})

-

{valuation.totalValue.toFixed(2)}

-
+
+ + +
{valuation.layers.length === 0 ? ( @@ -147,12 +164,29 @@ function ValuationContent() { <>
-

Value by FIFO layer

+

Stock value by purchase batch

new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }))} - datasets={[{ label: `Value (${valuation.currency})`, data: valuation.layers.map((l) => l.value), backgroundColor: "rgba(59,130,246,0.6)" }]} - barPercentage={0.4} - categoryPercentage={0.95} + // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 layers, + // Chart.js would otherwise center the lone category across the full width. + // Trailing blank slots keep the axis/gridlines spanning the full card while + // the real bars sit at the start. Skipped on mobile — there the bars should + // stretch to fill the narrower card instead. + labels={[ + ...valuation.layers.map((l) => new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })), + ...(isMobile ? [] : Array(Math.max(0, 5 - valuation.layers.length)).fill("")), + ]} + datasets={[ + { + label: `Value (${valuation.currency})`, + data: [ + ...valuation.layers.map((l) => l.value), + ...(isMobile ? [] : Array(Math.max(0, 5 - valuation.layers.length)).fill(null)), + ], + backgroundColor: "rgba(34,197,94,0.6)", + }, + ]} + barPercentage={0.6} + categoryPercentage={0.6} />
@@ -164,42 +198,63 @@ function ValuationContent() { { label: `Value (${valuation.currency})`, data: valuation.layers.map((l) => l.value), - borderColor: "#3b82f6", - backgroundColor: "rgba(59,130,246,0.15)", + borderColor: "rgba(34,197,94,1)", + backgroundColor: "rgba(34,197,94,0.15)", }, { label: `Cumulative value (${valuation.currency})`, data: valuation.layers.reduce((acc, l) => [...acc, (acc[acc.length - 1] ?? 0) + l.value], []), - borderColor: "#22c55e", - backgroundColor: "rgba(34,197,94,0.15)", + borderColor: "rgba(239,68,68,1)", + backgroundColor: "rgba(239,68,68,0.15)", }, ]} />
- - - - Layer - Receipt date - Qty remaining - Unit cost - Value - - - + {/* Mobile: stacked cards. */} +
{valuation.layers.map((layer) => ( - - {layer.layerId} - {new Date(layer.receiptDate).toLocaleString()} - {layer.qtyRemaining} - {layer.unitCost.toFixed(2)} - {layer.value.toFixed(2)} - +
+
+ Layer #{layer.layerId} + {new Date(layer.receiptDate).toLocaleDateString()} +
+
+ {layer.qtyRemaining} @ {layer.unitCost.toFixed(2)} + {layer.value.toFixed(2)} +
+
))} - -
+
+ + {/* Desktop: table. */} +
+
+ + + + Layer + Receipt date + Qty remaining + Unit cost + Value + + + + {valuation.layers.map((layer) => ( + + {layer.layerId} + {new Date(layer.receiptDate).toLocaleString()} + {layer.qtyRemaining} + {layer.unitCost.toFixed(2)} + {layer.value.toFixed(2)} + + ))} + +
+
+
)} diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx index 7996847..b1bc537 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx @@ -276,11 +276,11 @@ export default function NewWastagePage() {
{submitError}
)} -
- +
+ Cancel -
diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx index 837e172..ebec708 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx @@ -14,13 +14,16 @@ import { ReasonCode } from "@/types/stock" import { ItemListItem, Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Label } from "@/components/ui/label" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] + export default function WastagePage() { const { activeBranchId, branchingEnabled } = useAuth() @@ -32,6 +35,8 @@ export default function WastagePage() { const [warehouseId, setWarehouseId] = useState("All") const [reasonCodeId, setReasonCodeId] = useState("All") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) useEffect(() => { Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) @@ -49,15 +54,23 @@ export default function WastagePage() { warehouseId: warehouseId === "All" ? undefined : warehouseId, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId, + pageSize: 200, }) .then(setRecords) .catch((err) => setError(errorMessage(err))) }, [warehouseId, reasonCodeId, activeBranchId, branchingEnabled]) + useEffect(() => setPage(1), [warehouseId, reasonCodeId]) + const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) const reasonById = useMemo(() => new Map((reasonCodes ?? []).map((r) => [r.reasonCodeId, r])), [reasonCodes]) + const pagination = records + ? { page, pageSize, totalItems: records.length, totalPages: Math.max(1, Math.ceil(records.length / pageSize)) } + : null + const pagedRecords = records?.slice((page - 1) * pageSize, page * pageSize) ?? null + const totals = useMemo(() => { if (!records) return { qty: 0, value: 0 } return records.reduce( @@ -79,7 +92,7 @@ export default function WastagePage() {

- + Record Wastage @@ -138,9 +151,21 @@ export default function WastagePage() {
{loading && !error && ( -
+
+
+ + +
{Array.from({ length: 4 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -152,45 +177,113 @@ export default function WastagePage() {
)} - {!loading && !error && records.length > 0 && ( - - - - Date - Doc No - Item - Warehouse - Reason - Qty wasted - Value (LKR) - - - - {records.map((r) => { + {!loading && !error && pagedRecords !== null && pagedRecords.length > 0 && ( + <> + {/* Mobile: stacked cards, "Show more" instead of pagination. */} +
+ {records!.slice(0, page * pageSize).map((r) => { const item = itemsById.get(r.itemId) const wh = warehousesById.get(r.warehouseId) const reason = reasonById.get(r.reasonCodeId) return ( - - {new Date(r.createdAt).toLocaleString()} - {r.docNo} - -
{item?.sku ?? `#${r.itemId}`}
-
{item?.name}
-
- {wh?.code ?? `#${r.warehouseId}`} - - +
+
+
+

{item?.sku ?? `#${r.itemId}`}

+

{item?.name}

+
+ {reason?.description ?? `#${r.reasonCodeId}`} - - -{r.qty} - {r.value.toFixed(2)} - +
+ +
+
+

Doc No

+

{r.docNo}

+
+
+

Warehouse

+

{wh?.code ?? `#${r.warehouseId}`}

+
+
+

Qty wasted

+

-{r.qty}

+
+
+

Value (LKR)

+

{r.value.toFixed(2)}

+
+
+

Date

+

{new Date(r.createdAt).toLocaleString()}

+
+
+
) })} - -
+ + {records && page * pageSize < records.length && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Doc No + Date + SKU + Item name + Warehouse + Reason + Qty wasted + Value (LKR) + + + + {pagedRecords.map((r) => { + const item = itemsById.get(r.itemId) + const wh = warehousesById.get(r.warehouseId) + const reason = reasonById.get(r.reasonCodeId) + return ( + + {r.docNo} + {new Date(r.createdAt).toLocaleString()} + {item?.sku ?? `#${r.itemId}`} + {item?.name} + {wh?.code ?? `#${r.warehouseId}`} + + + {reason?.description ?? `#${r.reasonCodeId}`} + + + -{r.qty} + {r.value.toFixed(2)} + + ) + })} + +
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} + )}
) diff --git a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx index f7236d9..1671a19 100644 --- a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { useParams } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, BadgeDollarSign, PackageCheck, Pencil, Save, Truck } from "lucide-react" +import { AlertTriangle, ArrowLeft, BadgeDollarSign, PackageCheck, Pencil, Save, ShoppingCart, Truck } from "lucide-react" import { vendorsApi } from "@/lib/api/vendors" import { grnsApi } from "@/lib/api/grns" @@ -19,11 +19,14 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" import { Badge } from "@/components/ui/badge" +import { PaginationFooter } from "@/components/ui/pagination-footer" 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" +const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20] + /** Small tinted stat chip for the profile header — value + label + icon. */ function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) { return ( @@ -126,18 +129,42 @@ export default function VendorDetailPage() { 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 ?? []).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), [grns] ) + + const [grnPage, setGrnPage] = useState(1) + const [grnPageSize, setGrnPageSize] = useState(GRN_PAGE_SIZE_OPTIONS[0]) + const [mobileGrnCount, setMobileGrnCount] = useState(GRN_PAGE_SIZE_OPTIONS[0]) + const grnPagination = { + page: grnPage, + pageSize: grnPageSize, + totalItems: recentGrns.length, + totalPages: Math.max(1, Math.ceil(recentGrns.length / grnPageSize)), + } + const pagedGrns = recentGrns.slice((grnPage - 1) * grnPageSize, grnPage * grnPageSize) 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) + return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5) }, [grns]) + // 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. + // Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the + // narrow card instead of leaving blank trailing slots — there's less width to spare there. + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const mql = window.matchMedia("(max-width: 639px)") + setIsMobile(mql.matches) + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, []) + async function handleSave() { setSaveError(null) const nextErrors: Record = {} @@ -237,14 +264,15 @@ export default function VendorDetailPage() {
-
-
-
+
-

Monthly purchases

+
+ +

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)" }]} + // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of + // activity, Chart.js would otherwise center the lone category across the full + // width. Trailing blank slots keep the axis/gridlines spanning the full card + // while the real bars sit at the start. Skipped on mobile — there the bars + // should stretch to fill the narrower card instead. + labels={[ + ...monthlyPurchases.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })), + ...(isMobile ? [] : Array(Math.max(0, 5 - monthlyPurchases.length)).fill("")), + ]} + datasets={[ + { + label: "Purchased (LKR)", + data: [ + ...monthlyPurchases.map(([, v]) => v), + ...(isMobile ? [] : Array(Math.max(0, 5 - monthlyPurchases.length)).fill(null)), + ], + backgroundColor: "rgba(34,197,94,0.6)", + }, + ]} + barPercentage={0.6} + categoryPercentage={0.6} /> ) : (

No purchases recorded yet.

@@ -297,34 +347,99 @@ export default function VendorDetailPage() {

Recent purchases (GRNs)

{grns === null ? ( - +
+
+ + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
) : 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 })} - + <> + {/* Mobile: stacked cards, "Show more" instead of pagination. */} +
+ {recentGrns.slice(0, mobileGrnCount).map((g) => ( + +
+ {g.docNo} + {new Date(g.createdAt).toLocaleDateString()} +
+
+ {g.lineCount} line{g.lineCount === 1 ? "" : "s"} + LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+ ))} - -
+ + {mobileGrnCount < recentGrns.length && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Date + Document + Lines + Value + + + + {pagedGrns.map((g) => ( + + {new Date(g.createdAt).toLocaleDateString()} + + + {g.docNo} + + + {g.lineCount} + + LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} + + + ))} + +
+
+
+ +
+ { setGrnPage(1); setGrnPageSize(size) }} + /> +
+ )}
diff --git a/Frontend/erp-system/app/dashboard/vendors/page.tsx b/Frontend/erp-system/app/dashboard/vendors/page.tsx index a96b6af..eb8b394 100644 --- a/Frontend/erp-system/app/dashboard/vendors/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { Pencil, Plus, Search, Trash2, Truck } from "lucide-react" +import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react" import { vendorsApi } from "@/lib/api/vendors" import { branchesHrmApi } from "@/lib/api/hrm-masters" @@ -36,7 +36,10 @@ import { toast } from "@/components/ui/toast" type StatusFilter = EntityStatus | "All" -const PAGE_SIZE = 25 +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 export default function VendorsPage() { const router = useRouter() @@ -51,6 +54,7 @@ export default function VendorsPage() { const [query, setQuery] = useState("") const [status, setStatus] = useState("All") const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [open, setOpen] = useState(false) const [name, setName] = useState("") @@ -65,6 +69,7 @@ export default function VendorsPage() { const [allVendorCodes, setAllVendorCodes] = useState([]) const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : "" @@ -79,15 +84,21 @@ export default function VendorsPage() { function load() { vendorsApi - .list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize: PAGE_SIZE }) + .list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize }) .then((res) => { setVendors(res.items) setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) } - useEffect(load, [query, status, page]) + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) + } + + useEffect(load, [query, status, page, pageSize]) useEffect(() => { vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {}) @@ -263,9 +274,21 @@ export default function VendorsPage() { )} {!error && vendors === null && ( -
+
+
+ + +
{Array.from({ length: 4 }).map((_, i) => ( - +
+ +
+ + +
+ + +
))}
)} @@ -281,88 +304,209 @@ export default function VendorsPage() { {!error && visibleVendors !== null && visibleVendors.length > 0 && ( <> - - - - Code - Name - Terms - Currency - {isAdmin && branchingEnabled && Branch} - Status - Actions - - - - {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 ?? "—"} - {v.currency} + {/* Mobile: stacked cards. */} +
+ {visibleVendors.map((v) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/vendors/${v.vendorId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/vendors/${v.vendorId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+

{v.code}

+

{v.name}

+
+ + {v.status} + +
+ +
+
+

Terms

+

{v.terms ?? "—"}

+
+
+

Currency

+

{v.currency}

+
{isAdmin && branchingEnabled && ( - - {branchName(v.branchId)} - - )} - - - {v.status} - - - -
- - - - - - - } - > - - - handleDeactivate(v)} - /> - +
+

Branch

+ + {branchName(v.branchId)} +
- - - ))} - -
+ )} +
- {pagination && } +
+ + + + + + + } + > + + + handleDeactivate(v)} + /> + +
+
+ ))} + + {pagination && visibleVendors.length < pagination.totalItems && ( + + )} +
+ + {/* Desktop: table. */} +
+
+ + + + Code + Name + Terms + Currency + {isAdmin && branchingEnabled && Branch} + Status + Actions + + + + {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 ?? "—"} + {v.currency} + {isAdmin && branchingEnabled && ( + + {branchName(v.branchId)} + + )} + + + {v.status} + + + +
+ + + + + + + + + + } + > + + + handleDeactivate(v)} + /> + +
+
+
+ ))} +
+
+
+
+ + {pagination && ( +
+ { setPage(1); setPageSize(size) }} + /> +
+ )} )}
diff --git a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx index fa1b1ac..a77faeb 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx @@ -49,41 +49,58 @@ function TablePager({ if (total === 0) return null return ( -
-
-

- Showing {(page - 1) * pageSize + 1}– - {Math.min(page * pageSize, total)}{" "} - of {total} {itemLabel} -

- -
-
- - - Page {page} of {totalPages} - - + )} + + {/* Desktop: full pager. */} +
+
+

+ Showing {(page - 1) * pageSize + 1}– + {Math.min(page * pageSize, total)}{" "} + of {total} {itemLabel} +

+ +
+
+ + + Page {page} of {totalPages} + + +
-
+ ) } @@ -268,7 +285,18 @@ export default function WarehouseDetailPage() {
) : (
-
+ {/* Mobile: stacked rows. */} +
+ {bins.slice(0, binsPage * binsPageSize).map((b) => ( +
+ {b.code} + {b.binType ?? "—"} +
+ ))} +
+ + {/* Desktop: table. */} +
@@ -332,13 +360,51 @@ export default function WarehouseDetailPage() { r.onHand), backgroundColor: "#6366f1" }, - { label: "Available", data: chartRows.map((r) => r.available), backgroundColor: "#22c55e" }, + { label: "On hand", data: chartRows.map((r) => r.onHand), backgroundColor: "rgba(239,68,68,0.6)" }, + { label: "Available", data: chartRows.map((r) => r.available), backgroundColor: "rgba(34,197,94,0.6)" }, ]} /> -
+ {/* Mobile: stacked cards. */} +
+ {stockRows.slice(0, stockPage * stockPageSize).map((row) => { + const item = itemsById.get(row.itemId) + return ( +
+
+

{item?.sku ?? `#${row.itemId}`}

+

{item?.name}

+
+
+
+

On hand

+

{row.onHand}

+
+
+

Available

+

{row.available}

+
+
+

On hold

+

{row.onHold}

+
+
+

In transit

+

{row.inTransit}

+
+
+

Reserved

+

{row.reserved}

+
+
+
+ ) + })} +
+ + {/* Desktop: table. */} +
@@ -414,13 +480,34 @@ export default function WarehouseDetailPage() { r.in), backgroundColor: "#22c55e" }, - { label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "#ef4444" }, + { label: "In", data: movementChartRows.map((r) => r.in), backgroundColor: "rgba(34,197,94,0.6)" }, + { label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "rgba(239,68,68,0.6)" }, ]} /> -
+ {/* Mobile: stacked cards. */} +
+ {movementByItem.slice(0, movementPage * movementPageSize).map((row) => { + const item = itemsById.get(row.itemId) + return ( +
+
+ {item?.sku ?? `#${row.itemId}`} + {item?.name} +
+
+ In +{row.in} + Out −{row.out} + Net {row.in - row.out} +
+
+ ) + })} +
+ + {/* Desktop: table. */} +
diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index 2cc67a5..0560e7f 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -3,18 +3,19 @@ import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react" +import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react" import { warehousesApi } from "@/lib/api/warehouses" import { branchesHrmApi } from "@/lib/api/hrm-masters" import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" import { PaginationMeta } from "@/types/common" import { Bin, Warehouse } from "@/types/master-data" import { Branch } from "@/types/hrm" import { useAuth } from "@/components/auth/AuthProvider" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" -import { Button } from "@/components/ui/button" +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" @@ -41,6 +42,9 @@ function warehouseCodeBase(name: string): string { } const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] +// Mobile has no page-size selector — tapping "Show more" just grows the page size and +// re-fetches from page 1, so previously loaded cards stay put and more append below. +const MOBILE_PAGE_SIZE_STEP = 10 /** Appends a numeric suffix until the code doesn't collide with an existing one — the * backend enforces global uniqueness (409 on conflict) but has no generation of its own. */ @@ -85,6 +89,7 @@ export default function WarehousesPage() { const [editSubmitting, setEditSubmitting] = useState(false) const [actionPendingId, setActionPendingId] = useState(null) + const [showMoreLoading, setShowMoreLoading] = useState(false) const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : "" @@ -98,6 +103,12 @@ export default function WarehousesPage() { setBins(allBins.flat()) }) .catch((err) => setError(errorMessage(err))) + .finally(() => setShowMoreLoading(false)) + } + + function handleShowMore() { + setShowMoreLoading(true) + setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } useEffect(load, [page, pageSize, debouncedSearch, filterBranchId]) @@ -332,92 +343,174 @@ export default function WarehousesPage() { )} {!loading && !error && warehouses !== null && warehouses.length > 0 && ( -
-
-
- - - Code - Name - {warehouseBranchingEnabled && Branch} - Bins - Actions - - - - {warehouses.map((w) => ( - { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/warehouse/${w.warehouseId}`) - }} - > - - - {w.code} - - + <> + {/* Mobile: stacked cards. */} +
+ {warehouses.map((w) => ( +
{ + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/warehouse/${w.warehouseId}`) + }} + onKeyDown={(e) => { + if (e.key === "Enter") router.push(`/dashboard/warehouse/${w.warehouseId}`) + }} + className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" + > +
+
+

{w.code}

+

{w.name}

+
+ {warehouseBranchingEnabled && w.branchName && ( + + {w.branchName} + + )} +
- {w.name} +
+

Bins

+

{binCount(w.warehouseId)}

+
- {warehouseBranchingEnabled && ( - - {w.branchName ? ( - {w.branchName} - ) : ( - — - )} - - )} +
+ + + + } + > + + + handleDelete(w)} + /> + +
+
+ ))} - {binCount(w.warehouseId)} - - -
- - - - } - > - - - handleDelete(w)} - /> - -
-
- - ))} - -
+ {pagination && warehouses.length < pagination.totalItems && ( + + )}
-
+ + {/* Desktop: table. */} +
+
+ + + + Code + Name + {warehouseBranchingEnabled && Branch} + Bins + Actions + + + + {warehouses.map((w) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + router.push(`/dashboard/warehouse/${w.warehouseId}`) + }} + > + + + {w.code} + + + + {w.name} + + {warehouseBranchingEnabled && ( + + {w.branchName ? ( + {w.branchName} + ) : ( + — + )} + + )} + + {binCount(w.warehouseId)} + + +
+ + + + + + + } + > + + + handleDelete(w)} + /> + +
+
+
+ ))} +
+
+
+
+ )} {pagination && pagination.totalItems > 0 && ( -
-
+
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}– diff --git a/Frontend/erp-system/components/Layouts/Header.tsx b/Frontend/erp-system/components/Layouts/Header.tsx index c7e8ad1..93d2da0 100644 --- a/Frontend/erp-system/components/Layouts/Header.tsx +++ b/Frontend/erp-system/components/Layouts/Header.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react" import Link from "next/link" import { usePathname, useRouter } from "next/navigation" -import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User } from "lucide-react" +import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User, X } from "lucide-react" import { authApi } from "@/lib/api/auth" import { searchApi } from "@/lib/api/search" @@ -201,7 +201,9 @@ export function Header() { const [searchQuery, setSearchQuery] = useState("") const [searchResults, setSearchResults] = useState([]) const [searchOpen, setSearchOpen] = useState(false) + const [mobileSearchOpen, setMobileSearchOpen] = useState(false) const searchBoxRef = useRef(null) + const mobileSearchBoxRef = useRef(null) useEffect(() => { const term = searchQuery.trim() @@ -221,6 +223,9 @@ export function Header() { if (searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) { setSearchOpen(false) } + if (mobileSearchBoxRef.current && !mobileSearchBoxRef.current.contains(e.target as Node)) { + setMobileSearchOpen(false) + } } document.addEventListener("mousedown", onClickOutside) return () => document.removeEventListener("mousedown", onClickOutside) @@ -228,6 +233,7 @@ export function Header() { function goToSearchResult(result: GlobalSearchResult) { setSearchOpen(false) + setMobileSearchOpen(false) setSearchQuery("") setSearchResults([]) router.push(SEARCH_TYPE_META[result.type].href(result.id)) @@ -263,7 +269,8 @@ export function Header() { } return ( -

+
+
{showBackButton && ( + {branchingEnabled && (isAdmin ? ( setSearchQuery(e.target.value)} + placeholder="Search item, PO, invoice, employee…" + className="h-10 w-full rounded-full border border-transparent bg-muted pr-9 pl-9 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" + /> + + + {searchQuery.trim().length >= 2 && ( +
+ {searchResults.length === 0 ? ( +

No matches found.

+ ) : ( + searchGrouped.map((group) => { + const meta = SEARCH_TYPE_META[group.type] + const Icon = meta.icon + return ( +
+

{meta.label}

+ {group.items.map((item) => ( + + ))} +
+ ) + }) + )} +
+ )} +
+ )}
) } diff --git a/Frontend/erp-system/components/auth/RolePermissionActions.tsx b/Frontend/erp-system/components/auth/RolePermissionActions.tsx index f8b7288..49b7d89 100644 --- a/Frontend/erp-system/components/auth/RolePermissionActions.tsx +++ b/Frontend/erp-system/components/auth/RolePermissionActions.tsx @@ -164,7 +164,7 @@ export function RolePermissionActions({ return (
-
+
Section Actions
@@ -172,7 +172,7 @@ export function RolePermissionActions({ {groups.map((group) => (
{group.own ? ( -
+