@@ -490,54 +501,13 @@ export default function EmployeesPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} employees
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx
index 4976ab3..261265c 100644
--- a/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx
+++ b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
-import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react"
+import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -456,54 +456,13 @@ export default function LeaveRequestsPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} leave requests
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx
index 1700b74..291e9da 100644
--- a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx
+++ b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
-import { BadgeDollarSign, ChevronLeft, ChevronRight, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
+import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
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 { toast } from "@/components/ui/toast"
@@ -328,52 +329,13 @@ export default function PayrollRunDetailPage() {
)}
{filteredLines.length > 0 && (
-
-
-
- Showing {(currentPage - 1) * pageSize + 1} –
- {Math.min(currentPage * pageSize, filteredLines.length)} {" "}
- of {filteredLines.length} lines
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {currentPage} of {totalPages}
-
- = totalPages}
- onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx
index 94a73ed..0e9d450 100644
--- a/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx
+++ b/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
-import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
+import { Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+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"
@@ -238,54 +239,13 @@ export default function PayrollRunsPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} payroll runs
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx
index 42503a0..f5f6a13 100644
--- a/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx
+++ b/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
-import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
+import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -562,54 +562,13 @@ export default function DepartmentsPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} departments
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
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 2380de8..01ecf2a 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
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
-import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
+import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -534,54 +534,13 @@ export default function DocumentTypesPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} document types
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
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 50d1240..a14f4a9 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
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
-import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
+import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -559,54 +559,13 @@ export default function LeaveTypesPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} leave types
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
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 34a47cf..5ad144b 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
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
-import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
+import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -527,54 +527,13 @@ export default function SalaryComponentsPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} salary components
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
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 4005411..c3c944c 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
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
-import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
+import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -629,54 +629,13 @@ export default function WorkShiftsPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} work shifts
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/app/dashboard/production/produced/page.tsx b/Frontend/erp-system/app/dashboard/production/produced/page.tsx
index ddf4d15..7123347 100644
--- a/Frontend/erp-system/app/dashboard/production/produced/page.tsx
+++ b/Frontend/erp-system/app/dashboard/production/produced/page.tsx
@@ -9,6 +9,7 @@ import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
+import { PaginationMeta } from "@/types/common"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data"
@@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
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"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -34,6 +36,10 @@ function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
+// The produced list comes back whole (no server paging), so pages are sliced client-side from
+// the already-loaded lines. The summary totals still cover the whole filtered list.
+const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
+
export default function ProducedItemsPage() {
const router = useRouter()
@@ -49,6 +55,8 @@ export default function ProducedItemsPage() {
const [templateId, setTemplateId] = useState
(null)
const [warehouseId, setWarehouseId] = useState(null)
const [status, setStatus] = useState("All")
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const load = useCallback(() => {
setLoadError(null)
@@ -60,7 +68,11 @@ export default function ProducedItemsPage() {
warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status,
})
- .then(setData)
+ .then((res) => {
+ setData(res)
+ // A new filter is a new list — start back at the first page.
+ setPage(1)
+ })
.catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err))
@@ -80,26 +92,37 @@ export default function ProducedItemsPage() {
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
+ const totalItems = data?.items.length ?? 0
+ const pagination: PaginationMeta = {
+ page,
+ pageSize,
+ totalItems,
+ totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
+ }
+ const pageItems = data?.items.slice((page - 1) * pageSize, page * pageSize) ?? []
+
return (
-
Produced Items
-
+
Produced Items
+
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
-
-
-
From
-
setFrom(e.target.value)} className="h-11 text-base" />
+ {/* Two-up on phones (date range side by side, then template/warehouse), status full width —
+ five stacked labelled controls would push the results well below the fold. */}
+
+
+ From
+ setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
-
-
To
-
setTo(e.target.value)} className="h-11 text-base" />
+
+ To
+ setTo(e.target.value)} className="h-11 min-w-0 text-base" />
-
-
Template
+
+ Template
value={templateId} onValueChange={setTemplateId}>
@@ -111,8 +134,8 @@ export default function ProducedItemsPage() {
-
-
Warehouse
+
+ Warehouse
value={warehouseId} onValueChange={setWarehouseId}>
@@ -124,8 +147,8 @@ export default function ProducedItemsPage() {
-
-
Status
+
+ Status
value={status} onValueChange={(v) => setStatus(v ?? "All")}>
@@ -175,67 +198,148 @@ export default function ProducedItemsPage() {
) : (
-
-
-
-
- Doc No
- Status
- Template
- Warehouse
- Item
- Produced
- Scrapped
- Good Qty
- Unit Cost
- Cost of Production
- Completed
-
-
-
- {data.items.map((line) => (
- router.push(`/dashboard/production/runs/${line.runId}`)}
- >
- {line.docNo}
-
-
- {line.status === "InProgress" ? "In Progress" : line.status}
-
-
- {line.templateName}
- {line.warehouseName}
- {line.itemName}
- {line.producedQty.toLocaleString()}
- {line.scrappedQty.toLocaleString()}
- {line.goodQty.toLocaleString()}
- {formatCost(line.unitCost)}
- {formatCost(line.totalCost)}
-
- {line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
-
+ <>
+ {/* Mobile: totals first (the figure people come here for), then one card per line. */}
+
+
+
+ Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
+
+
Cost of production
+
{formatCost(data.summary.totalCost)}
+
+
+
Produced
+
{data.summary.totalProducedQty.toLocaleString()}
+
+
+
Scrapped
+
{data.summary.totalScrappedQty.toLocaleString()}
+
+
+
Good
+
{data.summary.totalGoodQty.toLocaleString()}
+
+
+
+
+ {pageItems.map((line) => (
+
router.push(`/dashboard/production/runs/${line.runId}`)}
+ className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
+ >
+
+
+
{line.docNo}
+
{line.itemName}
+
+
+ {line.status === "InProgress" ? "In Progress" : line.status}
+
+
+
+
+
+
Produced
+
{line.producedQty.toLocaleString()}
+
+
+
Scrapped
+
{line.scrappedQty.toLocaleString()}
+
+
+
Good
+
{line.goodQty.toLocaleString()}
+
+
+
Unit cost
+
{formatCost(line.unitCost)}
+
+
+
Cost of production
+
{formatCost(line.totalCost)}
+
+
+
+
+ {line.templateName} · {line.warehouseName}
+ {line.completedAt && ` · Completed ${new Date(line.completedAt).toLocaleDateString()}`}
+
+
+ ))}
+
+
+
+
+
+
+ Doc No
+ Status
+ Template
+ Warehouse
+ Item
+ Produced
+ Scrapped
+ Good Qty
+ Unit Cost
+ Cost of Production
+ Completed
- ))}
-
-
-
-
- Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
-
- {data.summary.totalProducedQty.toLocaleString()}
- {data.summary.totalScrappedQty.toLocaleString()}
- {data.summary.totalGoodQty.toLocaleString()}
-
-
- Cost of Production: {formatCost(data.summary.totalCost)}
-
-
-
-
-
-
+
+
+ {pageItems.map((line) => (
+ router.push(`/dashboard/production/runs/${line.runId}`)}
+ >
+ {line.docNo}
+
+
+ {line.status === "InProgress" ? "In Progress" : line.status}
+
+
+ {line.templateName}
+ {line.warehouseName}
+ {line.itemName}
+ {line.producedQty.toLocaleString()}
+ {line.scrappedQty.toLocaleString()}
+ {line.goodQty.toLocaleString()}
+ {formatCost(line.unitCost)}
+ {formatCost(line.totalCost)}
+
+ {line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
+
+
+ ))}
+
+
+
+
+ Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
+
+ {data.summary.totalProducedQty.toLocaleString()}
+ {data.summary.totalScrappedQty.toLocaleString()}
+ {data.summary.totalGoodQty.toLocaleString()}
+
+
+ Cost of Production: {formatCost(data.summary.totalCost)}
+
+
+
+
+
+
+
+
{ setPage(1); setPageSize(size) }}
+ />
+ >
)}
)
diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/RunActions.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/RunActions.tsx
index e726a9f..92f2e11 100644
--- a/Frontend/erp-system/app/dashboard/production/runs/[id]/RunActions.tsx
+++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/RunActions.tsx
@@ -140,7 +140,7 @@ export function RunActions({
return (
<>
-
+
Return leftover
diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx
index 081d561..45740f1 100644
--- a/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx
+++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx
@@ -19,6 +19,7 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import {
+ RunStageCardBody,
RunHeaderNodeComponent,
RunStageNodeComponent,
type RunHeaderData,
@@ -50,7 +51,75 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
*/
const HEADER_GAP_X = 240
-function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
+/** Card data per stage, keyed by runStageId — shared by the canvas nodes and the phone list. */
+function buildStageData(run: ProductionRunGraph): Map {
+ // The stage the operator is expected to act on: the furthest-along actionable one, so a run
+ // mid-flight highlights the stage in progress rather than the first thing still Waiting.
+ const actionable = ["InProgress", "Done", "Ready"] as const
+ const activeId =
+ run.status === "InProgress"
+ ? actionable.reduce(
+ (found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
+ null
+ )
+ : null
+
+ return new Map(
+ run.stages.map((stage) => {
+ const upstream = stage.inputs.filter((i) => i.source === "Upstream")
+ return [
+ stage.runStageId,
+ {
+ name: stage.name,
+ roleLabel: stage.roleLabel,
+ state: stage.status,
+ isTerminal: stage.isTerminal,
+ isEntry: stage.isEntry,
+ estimatedMinutes: stage.estimatedMinutes,
+ actualMinutes: stage.actualMinutes,
+ actualStartAt: stage.actualStartAt,
+ intake:
+ upstream.length === 0
+ ? null
+ : {
+ delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
+ planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
+ },
+ availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
+ isActive: stage.runStageId === activeId,
+ } satisfies RunStageData,
+ ]
+ })
+ )
+}
+
+/**
+ * Stages in flow order for the phone list: a topological sort, breaking ties by canvas position
+ * (left to right, then top to bottom) so parallel branches read the way they were drawn.
+ */
+function flowOrder(run: ProductionRunGraph): RunStage[] {
+ const byPosition = (a: RunStage, b: RunStage) => a.posX - b.posX || a.posY - b.posY
+ const inDegree = new Map(run.stages.map((s) => [s.runStageId, 0]))
+ for (const e of run.edges) inDegree.set(e.childRunStageId, (inDegree.get(e.childRunStageId) ?? 0) + 1)
+
+ const ready = run.stages.filter((s) => inDegree.get(s.runStageId) === 0)
+ const ordered: RunStage[] = []
+ while (ready.length > 0) {
+ ready.sort(byPosition)
+ const stage = ready.shift()!
+ ordered.push(stage)
+ for (const e of run.edges.filter((e) => e.parentRunStageId === stage.runStageId)) {
+ const next = (inDegree.get(e.childRunStageId) ?? 0) - 1
+ inDegree.set(e.childRunStageId, next)
+ if (next === 0) ready.push(run.stages.find((s) => s.runStageId === e.childRunStageId)!)
+ }
+ }
+ // The server guarantees a DAG, but never drop a stage from view if that ever fails to hold.
+ const seen = new Set(ordered.map((s) => s.runStageId))
+ return [...ordered, ...run.stages.filter((s) => !seen.has(s.runStageId)).sort(byPosition)]
+}
+
+function buildFlow(run: ProductionRunGraph, stageData: Map): { nodes: Node[]; edges: Edge[] } {
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
@@ -64,42 +133,12 @@ function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
},
]
- // The stage the operator is expected to act on: the furthest-along actionable one, so a run
- // mid-flight highlights the stage in progress rather than the first thing still Waiting.
- const actionable = ["InProgress", "Done", "Ready"] as const
- const activeId =
- run.status === "InProgress"
- ? actionable.reduce(
- (found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
- null
- )
- : null
-
for (const stage of run.stages) {
- const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({
id: String(stage.runStageId),
type: "runStage",
position: { x: stage.posX, y: stage.posY },
- data: {
- name: stage.name,
- roleLabel: stage.roleLabel,
- state: stage.status,
- isTerminal: stage.isTerminal,
- isEntry: stage.isEntry,
- estimatedMinutes: stage.estimatedMinutes,
- actualMinutes: stage.actualMinutes,
- actualStartAt: stage.actualStartAt,
- intake:
- upstream.length === 0
- ? null
- : {
- delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
- planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
- },
- availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
- isActive: stage.runStageId === activeId,
- } satisfies RunStageData,
+ data: stageData.get(stage.runStageId)!,
draggable: false,
})
}
@@ -173,10 +212,12 @@ export default function ProductionRunDetailPage() {
})
}, [])
+ const stageData = useMemo(() => (run ? buildStageData(run) : new Map()), [run])
const { nodes, edges } = useMemo(
- () => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
- [run]
+ () => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
+ [run, stageData]
)
+ const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
@@ -213,9 +254,9 @@ export default function ProductionRunDetailPage() {
if (!run) {
return (
-
+
-
+
)
}
@@ -292,9 +333,38 @@ export default function ProductionRunDetailPage() {
-
Click a stage to open it.
+
+ Tap a stage to open it.
+ Click a stage to open it.
+
-
+ {/* Phones get the stages as a list in flow order — the operator on the floor needs to
+ reach "their" stage in one tap, not pan and pinch around a canvas. */}
+
+ {orderedStages.map((stage, i) => {
+ const data = stageData.get(stage.runStageId)!
+ return (
+
+
+ {i + 1}
+
+ setSelectedStageId(stage.runStageId)}
+ className={cn(
+ "min-w-0 flex-1 rounded-2xl bg-card p-3 text-left shadow-sm ring-2 transition-colors active:bg-muted/50",
+ selectedStageId === stage.runStageId ? "ring-primary" : "ring-foreground/10"
+ )}
+ style={data.isActive && selectedStageId !== stage.runStageId ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
+ >
+
+
+
+ )
+ })}
+
+
+
{mounted ? (
(null)
const [warehouseId, setWarehouseId] = useState(null)
const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(PAGE_SIZE)
+ const [showMoreLoading, setShowMoreLoading] = useState(false)
const [templates, setTemplates] = useState([])
const [warehouses, setWarehouses] = useState([])
@@ -85,7 +90,7 @@ export default function ProductionRunsPage() {
productionRunsApi
.list({
page,
- pageSize: PAGE_SIZE,
+ pageSize,
q: query || undefined,
status: status === "All" ? undefined : status,
templateId: templateId ?? undefined,
@@ -99,10 +104,16 @@ export default function ProductionRunsPage() {
setRuns([])
setLoadError(errorMessage(err))
})
- }, [page, query, status, templateId, warehouseId])
+ .finally(() => setShowMoreLoading(false))
+ }, [page, pageSize, query, status, templateId, warehouseId])
useEffect(load, [load])
+ function handleShowMore() {
+ setShowMoreLoading(true)
+ setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
+ }
+
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
// Inactive template that still has historical runs; the start dialog narrows to Active
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
@@ -172,11 +183,11 @@ export default function ProductionRunsPage() {
-
Production Runs
-
All manufacturing runs with per-stage progress at a glance.
+
Production Runs
+
All manufacturing runs with per-stage progress at a glance.
- Start Run} />
+ Start Run} />
Start a run
@@ -266,54 +277,59 @@ export default function ProductionRunsPage() {
-
-
+ {/* Search full width, then the three filters as a compact grid on phones (four stacked
+ 56px controls pushed the runs below the fold); one row from lg, where `lg:contents`
+ lifts the selects into the outer flex row. */}
+
+
setSearchInput(e.target.value)}
placeholder="Search doc no…"
- className="h-14 w-full pl-11 text-base"
+ className="h-12 w-full pl-11 text-base sm:h-14"
aria-label="Search runs"
/>
-
- value={templateId}
- onValueChange={(v) => { setTemplateId(v); setPage(1) }}
- >
-
-
-
-
- {templates.map((t) => (
- {t.name}
- ))}
-
-
-
- value={warehouseId}
- onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
- >
-
-
-
-
- {warehouses.map((w) => (
- {w.name}
- ))}
-
-
-
value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
-
-
-
-
- All statuses
- In Progress
- Completed
- Cancelled
-
-
+
+
+ value={templateId}
+ onValueChange={(v) => { setTemplateId(v); setPage(1) }}
+ >
+
+
+
+
+ {templates.map((t) => (
+ {t.name}
+ ))}
+
+
+
+ value={warehouseId}
+ onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
+ >
+
+
+
+
+ {warehouses.map((w) => (
+ {w.name}
+ ))}
+
+
+ value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
+
+
+
+
+ All statuses
+ In Progress
+ Completed
+ Cancelled
+
+
+
{hasFilters && (
@@ -426,7 +442,17 @@ export default function ProductionRunsPage() {
})}
- {pagination &&
}
+ {pagination && runs.length < pagination.totalItems && (
+
+ {showMoreLoading ? "Loading…" : "Show more"}
+
+ )}
+
+ {pagination && (
+
+ )}
>
)}
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx
index 06947e0..de4d4e8 100644
--- a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx
@@ -233,7 +233,9 @@ export function StageEditorPanel({
return (
!next && onClose()}>
-
+ {/* No bare `max-w-lg`: it would override the base `max-w-[calc(100%-2rem)]` and run the
+ dialog edge to edge on a phone. dvh so mobile browser chrome doesn't hide the bottom. */}
+
Stage editor
@@ -301,7 +303,7 @@ export function StageEditorPanel({
{!readOnly && (
- removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
+ removeInput(input.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove input">
)}
@@ -404,7 +406,7 @@ export function StageEditorPanel({
/>
)}
{!readOnly && (
- removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
+ removeOutput(output.key)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove output">
)}
@@ -462,7 +464,7 @@ export function StageEditorPanel({
className="h-8 flex-1 text-sm"
/>
{!readOnly && (
- removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
+ removeField(field.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove field">
)}
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx
index 4c24214..5ac15f3 100644
--- a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx
@@ -24,14 +24,15 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
disconnected && "opacity-40"
)}
>
-
+ {/* Handles are finger-sized below md: dragging a connection starts from them. */}
+
{selected && data.onDelete && (
{
e.stopPropagation()
data.onDelete?.()
@@ -60,7 +61,7 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
{disconnected && Disconnected
}
-
+
)
}
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx
index f7c27c3..eeffae2 100644
--- a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx
+++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
if (!loaded) {
return (
-
+
)
@@ -661,12 +661,15 @@ function TemplateBuilderContent() {
}
return (
-
-
-
-
-
-
{name || "Untitled template"}
+ // Phones stack naturally and scroll the page; the canvas gets its own fixed height below.
+ // Only from lg up does the builder fill the viewport with the canvas taking the remainder —
+ // on a short screen the fields and issue list would otherwise squeeze it to nothing.
+
+
+
+
+
+
{name || "Untitled template"}
{graph ? (
)}
-
+
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
{edges.length === 1 ? "" : "s"}
-
+ {/* Two-up grid on phones so every action is a full-width tap target; a single row from sm. */}
+
{!locked && (
<>
-
+
Add Stage
@@ -710,6 +714,7 @@ function TemplateBuilderContent() {
@@ -717,7 +722,7 @@ function TemplateBuilderContent() {
)}
{!locked && (
-
+
{saving ? "Saving…" : isNew ? "Create" : "Save"}
@@ -725,7 +730,7 @@ function TemplateBuilderContent() {
-
+
Code
setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
@@ -734,7 +739,7 @@ function TemplateBuilderContent() {
Name
setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
-
+
Description
)}
-
+
{mounted && (
-
+ {/* Covers too much of a phone-sized canvas to be worth it. */}
+
)}
diff --git a/Frontend/erp-system/app/dashboard/production/templates/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/page.tsx
index 1084ed4..0db0d02 100644
--- a/Frontend/erp-system/app/dashboard/production/templates/page.tsx
+++ b/Frontend/erp-system/app/dashboard/production/templates/page.tsx
@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
+import Link from "next/link"
import { useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
import "@xyflow/react/dist/style.css"
@@ -9,6 +10,7 @@ import { LayoutTemplate, Plus, Search } from "lucide-react"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { errorMessage } from "@/lib/error-map"
+import { cn } from "@/lib/utils"
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
import { PaginationMeta } from "@/types/common"
import {
@@ -29,6 +31,9 @@ import { Skeleton } from "@/components/ui/skeleton"
type StatusFilter = TemplateStatus | "All"
const PAGE_SIZE = 25
+// Mobile has no pagination — 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
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
@@ -92,6 +97,8 @@ export default function ProductionTemplatesPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState
("All")
const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(PAGE_SIZE)
+ const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -119,7 +126,7 @@ export default function ProductionTemplatesPage() {
productionTemplatesApi
.list({
page,
- pageSize: PAGE_SIZE,
+ pageSize,
q: query || undefined,
status: status === "All" ? undefined : status,
})
@@ -131,10 +138,16 @@ export default function ProductionTemplatesPage() {
setTemplates([])
setLoadError(errorMessage(err))
})
- }, [page, query, status])
+ .finally(() => setShowMoreLoading(false))
+ }, [page, pageSize, query, status])
useEffect(load, [load])
+ function handleShowMore() {
+ setShowMoreLoading(true)
+ setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
+ }
+
const hasFilters = query.length > 0 || status !== "All"
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
@@ -174,11 +187,11 @@ export default function ProductionTemplatesPage() {
-
Production Templates
-
Every production line, stage by stage. Click a line to open its builder.
+
Production Templates
+
Every production line, stage by stage. Tap a line to open its builder.
- New Template} />
+ New Template} />
New template
@@ -253,7 +266,7 @@ export default function ProductionTemplatesPage() {
)}
{templates === null ? (
-
+
) : templates.length === 0 ? (
@@ -263,7 +276,62 @@ export default function ProductionTemplatesPage() {
) : (
<>
-
+ {/* Phones get a list: a pan/zoom canvas of wide horizontal lines is unusable on a
+ narrow touch screen, so each line reads top to bottom instead. */}
+
+ {templates.map((t) => (
+
+
+
+
+
+ {t.status}
+
+ {t.activeRunCount > 0 && (
+
+ {t.activeRunCount} in progress
+
+ )}
+
+
+
+ {t.stageNames.length > 0 ? (
+
+ {t.stageNames.map((stageName, i) => (
+
+
+ {i + 1}
+
+ {stageName}
+
+ ))}
+
+ ) : (
+
No stages yet.
+ )}
+
+ ))}
+
+ {pagination && templates.length < pagination.totalItems && (
+
+ {showMoreLoading ? "Loading…" : "Show more"}
+
+ )}
+
+
+
{mounted ? (
- {pagination && }
+ {pagination && (
+
+ )}
>
)}
diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx
index 04f5de5..8d74779 100644
--- a/Frontend/erp-system/app/dashboard/products/page.tsx
+++ b/Frontend/erp-system/app/dashboard/products/page.tsx
@@ -25,6 +25,7 @@ import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils"
+import { useIsMobile } from "@/hooks/use-mobile"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
@@ -71,6 +72,7 @@ function SortableHeader({ label, active, order, onClick }: { label: string; acti
export default function ItemsPage() {
const router = useRouter()
+ const isMobile = useIsMobile()
const [items, setItems] = useState
(null)
const [categories, setCategories] = useState([])
const [brands, setBrands] = useState([])
@@ -84,6 +86,7 @@ export default function ItemsPage() {
const [categoryId, setCategoryId] = useState("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
+ const [loadingMore, setLoadingMore] = useState(false)
const [selected, setSelected] = useState>(new Set())
const [sortKey, setSortKey] = useState("sku")
@@ -95,10 +98,19 @@ export default function ItemsPage() {
return () => clearTimeout(timeout)
}, [searchInput])
- useEffect(() => setPage(1), [query, status, categoryId])
+ // A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
+ // pages) — restarts at page 1. Reset during render rather than in an effect.
+ const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
+ const [prevResetKey, setPrevResetKey] = useState(resetKey)
+ if (prevResetKey !== resetKey) {
+ setPrevResetKey(resetKey)
+ setPage(1)
+ }
- function load() {
- setError(null)
+ useEffect(() => {
+ let cancelled = false
+ // Mobile "Show more" appends the next server page to what's already listed.
+ const append = isMobile && page > 1
itemsApi
.overview({
page,
@@ -108,14 +120,27 @@ export default function ItemsPage() {
categoryId: categoryId === "All" ? undefined : categoryId,
})
.then((res) => {
- setItems(res.items)
+ if (cancelled) return
+ setError(null)
+ setItems((prev) => (append && prev ? [...prev, ...res.items] : res.items))
setPagination(res.pagination)
- setSelected(new Set())
+ if (!append) setSelected(new Set())
})
- .catch((err) => setError(errorMessage(err)))
- }
+ .catch((err) => {
+ if (!cancelled) setError(errorMessage(err))
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingMore(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [page, pageSize, query, status, categoryId, isMobile])
- useEffect(load, [page, pageSize, query, status, categoryId])
+ function showMore() {
+ setLoadingMore(true)
+ setPage((p) => p + 1)
+ }
useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
@@ -398,6 +423,12 @@ export default function ItemsPage() {
))}
+
+ {pagination && pagination.page < pagination.totalPages && (
+
+ {loadingMore ? "Loading…" : "Show more"}
+
+ )}
@@ -516,12 +547,14 @@ export default function ItemsPage() {
{pagination && (
-
{ setPage(1); setPageSize(size) }}
- />
+
+
{ setPage(1); setPageSize(size) }}
+ />
+
)}
>
)}
diff --git a/Frontend/erp-system/app/dashboard/products/tax-rates/page.tsx b/Frontend/erp-system/app/dashboard/products/tax-rates/page.tsx
index 9149ca2..86abbf9 100644
--- a/Frontend/erp-system/app/dashboard/products/tax-rates/page.tsx
+++ b/Frontend/erp-system/app/dashboard/products/tax-rates/page.tsx
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
-import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Percent, Plus, Search } from "lucide-react"
+import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, Pencil, Percent, Plus, Search } from "lucide-react"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { errorMessage } from "@/lib/error-map"
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+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"
@@ -345,39 +346,7 @@ export default function TaxRatesPage() {
- {pagination && pagination.totalPages > 1 && (
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1}–
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
-
- )}
+ {pagination && }
>
)}
diff --git a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx
index 42d2875..058f621 100644
--- a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx
+++ b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
-import { ChevronLeft, ChevronRight, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
+import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map"
@@ -382,39 +382,11 @@ export default function RolesPage() {
)}
{pagination && pagination.totalPages > 1 && (
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} roles
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
)}
diff --git a/Frontend/erp-system/app/dashboard/settings/users/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/page.tsx
index fc057ec..e5221fb 100644
--- a/Frontend/erp-system/app/dashboard/settings/users/page.tsx
+++ b/Frontend/erp-system/app/dashboard/settings/users/page.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
-import { ChevronLeft, ChevronRight, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
+import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
@@ -234,6 +234,14 @@ export default function UsersPage() {
{u.roleName ?? "—"}
+
+
+
+
+
+
0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} users
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ 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 a77faeb..09919bd 100644
--- a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx
+++ b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
-import { ArrowLeftRight, Boxes, ChevronLeft, ChevronRight, MapPinned, Plus } from "lucide-react"
+import { ArrowLeftRight, Boxes, MapPinned, Plus } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
@@ -23,6 +23,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
+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"
@@ -36,14 +37,13 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Reusable pager for a client-side-sliced table — data for these tables is fetched in
* full up front, so paging is done in-memory rather than via query params. */
function TablePager({
- page, pageSize, total, onPageChange, onPageSizeChange, itemLabel,
+ page, pageSize, total, onPageChange, onPageSizeChange,
}: {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
- itemLabel: string
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (total === 0) return null
@@ -65,40 +65,13 @@ function TablePager({
)}
{/* Desktop: full pager. */}
-
-
-
- Showing {(page - 1) * pageSize + 1} –
- {Math.min(page * pageSize, total)} {" "}
- of {total} {itemLabel}
-
-
- Rows per page
- onPageSizeChange(Number(e.target.value))}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- onPageChange(Math.max(1, page - 1))}>
-
- Previous
-
-
- Page {page} of {totalPages}
-
- = totalPages} onClick={() => onPageChange(Math.min(totalPages, page + 1))}>
- Next
-
-
-
+
>
)
@@ -322,7 +295,6 @@ export default function WarehouseDetailPage() {
total={bins.length}
onPageChange={setBinsPage}
onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }}
- itemLabel="bins"
/>
)}
@@ -444,7 +416,6 @@ export default function WarehouseDetailPage() {
total={stockRows.length}
onPageChange={setStockPage}
onPageSizeChange={(size) => { setStockPageSize(size); setStockPage(1) }}
- itemLabel="items"
/>
>
)}
@@ -543,7 +514,6 @@ export default function WarehouseDetailPage() {
total={movementByItem.length}
onPageChange={setMovementPage}
onPageSizeChange={(size) => { setMovementPageSize(size); setMovementPage(1) }}
- itemLabel="items"
/>
>
)}
diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx
index 0560e7f..a9eef3c 100644
--- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx
+++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
-import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
+import { Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -29,6 +29,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+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"
@@ -509,54 +510,13 @@ export default function WarehousesPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} warehouses
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/app/login/forgot/otp/page.tsx b/Frontend/erp-system/app/login/forgot/otp/page.tsx
index 0d22975..1f7e683 100644
--- a/Frontend/erp-system/app/login/forgot/otp/page.tsx
+++ b/Frontend/erp-system/app/login/forgot/otp/page.tsx
@@ -1,12 +1,15 @@
"use client"
import Link from "next/link"
+import { useRouter } from "next/navigation"
import { useState, useEffect } from "react"
+import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import OtpInput from "@/components/ui/otp-input"
import { Mail } from "lucide-react"
export default function ForgotOtpPage() {
+ const router = useRouter()
const [otp, setOtp] = useState("")
const [secondsLeft, setSecondsLeft] = useState(60)
@@ -24,47 +27,54 @@ export default function ForgotOtpPage() {
}
return (
-
-
-
-
-
-
-
Verify your email
-
-
We've sent a verification code to your email address. Enter it below to continue.
-
-
-
- setOtp(v)} />
-
-
-
- {canResend ? (
-
{
- e.preventDefault()
- handleResend()
- }}
- className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
- >
- Didn't receive the code? Resend
-
- ) : (
-
Resend code available in {Math.max(0, secondsLeft)}s
- )}
-
-
-
-
- Back
-
-
- Verify
-
-
+
+
+
+
+
Forgot password
+
Verify your email
+
+ We've sent a verification code to your email address. Enter it below to continue.
+
-
+
+
+
)
}
diff --git a/Frontend/erp-system/app/login/forgot/page.tsx b/Frontend/erp-system/app/login/forgot/page.tsx
index e78493b..b285527 100644
--- a/Frontend/erp-system/app/login/forgot/page.tsx
+++ b/Frontend/erp-system/app/login/forgot/page.tsx
@@ -1,7 +1,9 @@
"use client"
+import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
+import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { validateEmail } from "@/lib/validations"
@@ -18,52 +20,52 @@ export default function ForgotEmailPage() {
}
return (
-
-
-
-
Reset your password
-
Enter the email address associated with your account and we'll send you a one-time code to verify your identity.
-
-
-
-
-
Email address
-
onEmailChange((e.target as HTMLInputElement).value)}
- className="h-11 rounded-lg"
- placeholder="you@example.com"
- aria-invalid={emailError !== ""}
- />
- {emailError !== "" &&
{emailError}
}
-
-
-
- router.push('/login')}
- className="flex-1 h-12 rounded-full border-input text-foreground hover:bg-foreground/5"
- >
- Back
-
- {
- if (email.length === 0) {
- setEmailError('Email is required')
- return
- }
- if (emailError === "") router.push('/login/forgot/otp')
- }}
- >
- Send verification code
-
-
-
+
+
+
Forgot password
+
Reset your password
+
+ Enter the email address associated with your account and we'll send you a one-time code to verify your identity.
+
-
+
+
+
)
}
diff --git a/Frontend/erp-system/app/login/forgot/reset/page.tsx b/Frontend/erp-system/app/login/forgot/reset/page.tsx
index 856958a..10329a0 100644
--- a/Frontend/erp-system/app/login/forgot/reset/page.tsx
+++ b/Frontend/erp-system/app/login/forgot/reset/page.tsx
@@ -1,7 +1,9 @@
"use client"
+import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
+import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -13,6 +15,17 @@ import {
} from "@/components/ui/dialog"
import { Check, Eye, EyeOff } from "lucide-react"
+function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
+ return (
+
+
+ {met ? : ○ }
+
+
{label}
+
+ )
+}
+
export default function ForgotResetPage() {
const router = useRouter()
const [password, setPassword] = useState("")
@@ -28,115 +41,109 @@ export default function ForgotResetPage() {
const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
- const PasswordRequirement = ({ met, label }: { met: boolean; label: string }) => (
-
-
- {met ? : ○ }
-
-
{label}
-
- )
-
return (
-
-
-
-
Create a new password
-
Choose a strong password to secure your account.
-
-
-
-
-
New password
-
- setPassword((e.target as HTMLInputElement).value)}
- className="h-11 pr-11"
- placeholder="Enter your new password"
- />
- setShowPassword((s) => !s)}
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
- >
- {showPassword ? : }
-
-
-
-
-
-
Confirm password
-
- setConfirm((e.target as HTMLInputElement).value)}
- className="h-11 pr-11"
- placeholder="Confirm your new password"
- />
- setShowConfirm((s) => !s)}
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
- >
- {showConfirm ? : }
-
-
-
-
-
-
Password requirements:
-
-
-
-
-
-
- {!passwordsMatch && confirm.length > 0 && (
-
Passwords don't match.
- )}
-
-
- router.push('/login')}
- className="flex-1 h-12 rounded-full border-input text-foreground hover:bg-foreground/5"
- >
- Back
-
- setShowSuccess(true)}
- className="flex-1 h-12 rounded-full bg-foreground text-background hover:bg-foreground/90"
- >
- Change password
-
-
-
-
-
-
-
-
-
-
-
-
- Password changed successfully
- Your password has been updated. You can now sign in with your new password.
-
-
-
- router.push('/login')} className="w-4/5 mx-auto h-12 mt-6 rounded-full bg-foreground text-background hover:bg-foreground/90">
- Return to sign in
-
-
-
+
+
+
Forgot password
+
Create a new password
+
Choose a strong password to secure your account.
-
+
+
+
+
+
+
+
+
+
+
+
+ Password changed successfully
+ Your password has been updated. You can now sign in with your new password.
+
+
+
+ router.push("/login")} className="mx-auto mt-6 h-12 w-4/5 rounded-full text-base font-semibold">
+ Return to sign in
+
+
+
+
)
}
diff --git a/Frontend/erp-system/app/login/page.tsx b/Frontend/erp-system/app/login/page.tsx
index 3424e9c..4bd142b 100644
--- a/Frontend/erp-system/app/login/page.tsx
+++ b/Frontend/erp-system/app/login/page.tsx
@@ -1,7 +1,6 @@
"use client"
import { Suspense, useState } from "react"
-import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
@@ -12,6 +11,7 @@ import { authApi } from "@/lib/api/auth"
import { setStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map"
+import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -55,35 +55,7 @@ function LoginForm() {
})
return (
-
- {/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
- cutout (whithoutbackground.png) so there's no white scene box floating on the
- dark panel. */}
-
-
-
-
-
- {/* Login panel — overlays left side, no hard border with the photo */}
-
-
-
-
-
-
-
+
Sign in
Welcome back
@@ -174,13 +146,6 @@ function LoginForm() {
{Object.values(form.formState.errors).length > 0 && "There are errors in the form."}
-
-
-
- Don't have an account? Create an account
-
-
-
-
+
)
}
diff --git a/Frontend/erp-system/components/Layouts/Header.tsx b/Frontend/erp-system/components/Layouts/Header.tsx
index 93d2da0..8461978 100644
--- a/Frontend/erp-system/components/Layouts/Header.tsx
+++ b/Frontend/erp-system/components/Layouts/Header.tsx
@@ -22,7 +22,11 @@ import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
@@ -86,6 +90,14 @@ const STOCK_TITLES: Record = {
"/dashboard/stock/wastage/new": "Record Wastage",
}
+const PRODUCTION_TITLES: Record = {
+ "/dashboard/production": "Production",
+ "/dashboard/production/templates": "Production Templates",
+ "/dashboard/production/templates/new": "New Template",
+ "/dashboard/production/runs": "Production Runs",
+ "/dashboard/production/produced": "Produced Items",
+}
+
function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
@@ -103,6 +115,10 @@ function titleFromPath(pathname: string) {
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
+ if (PRODUCTION_TITLES[pathname]) return PRODUCTION_TITLES[pathname]
+ if (/^\/dashboard\/production\/templates\/[^/]+$/.test(pathname)) return "Template Builder"
+ if (/^\/dashboard\/production\/runs\/[^/]+$/.test(pathname)) return "Production Run"
+
if (PROCUREMENT_TITLES[pathname]) return PROCUREMENT_TITLES[pathname]
if (/^\/dashboard\/procurement\/requisitions\/[^/]+$/.test(pathname)) return "Requisition"
if (/^\/dashboard\/procurement\/rfqs\/[^/]+$/.test(pathname)) return "RFQ"
@@ -277,12 +293,16 @@ export function Header() {
type="button"
onClick={() => router.back()}
aria-label="Go back"
- className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
+ className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground sm:size-9"
>
)}
- {title}
+ {/* Phones wrap to two lines rather than truncating — at this width a one-line title
+ is cut to a few letters ("Prod…"). One truncated line from sm, where it fits. */}
+
+ {title}
+
@@ -377,7 +397,8 @@ export function Header() {
)}
-
+ {/* 108px wide — on phones it moves into the account menu so the title has room. */}
+
open && loadNotifications()}>
-
+
{user ? initials(displayName(user)) : "?"}
@@ -466,6 +487,39 @@ export function Header() {
)}
+
+ Theme
+
+
+ {/* The header's branch picker is hidden on phones, so admins switch here. Radio
+ items rather than a nested Select: a Select's portaled popup would read as an
+ outside press and close this menu. */}
+ {branchingEnabled && isAdmin && (
+
+
+
+
+
+ Branch
+
+
+ handleBranchChange(value)}
+ >
+
+ All Branches
+
+ {branches.map((b) => (
+
+ {b.name}
+
+ ))}
+
+
+
+
+ )}
}
diff --git a/Frontend/erp-system/components/auth/AuthShell.tsx b/Frontend/erp-system/components/auth/AuthShell.tsx
new file mode 100644
index 0000000..801adac
--- /dev/null
+++ b/Frontend/erp-system/components/auth/AuthShell.tsx
@@ -0,0 +1,40 @@
+import Image from "next/image"
+import Link from "next/link"
+
+/** Sign-in page chrome shared by the auth screens: illustration on the right, the form panel
+ * (logo + content) overlaying the left 45%. */
+export function AuthShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ {/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
+ cutout (whithoutbackground.png) so there's no white scene box floating on the
+ dark panel. */}
+
+
+
+
+
+ {/* Form panel — overlays left side, no hard border with the photo */}
+
+
+
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx b/Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx
index a13c1e7..e4abaf8 100644
--- a/Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx
+++ b/Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx
@@ -4,7 +4,7 @@
// (Branch, Designation, EmploymentType) — identical shape to each other, so one
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
import { useEffect, useState } from "react"
-import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
+import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -507,54 +508,13 @@ export function CodeNameMasterPage
({
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} {title.toLowerCase()}
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/components/production/RunStageNode.tsx b/Frontend/erp-system/components/production/RunStageNode.tsx
index 952e9b9..25757d8 100644
--- a/Frontend/erp-system/components/production/RunStageNode.tsx
+++ b/Frontend/erp-system/components/production/RunStageNode.tsx
@@ -52,25 +52,16 @@ function fmt(n: number): string {
}
/**
- * One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
- * from the template at creation) and colored by its live status. Clicking it opens the stage
- * drawer — handled by the page via `onNodeClick`, not here.
+ * The body of a stage card — everything but the frame. Shared by the canvas node below and the
+ * run page's phone list, so the two views of a stage can't drift apart.
*/
-function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
+export function RunStageCardBody({ data }: { data: RunStageData }) {
const color = STAGE_STATUS_COLOR[data.state]
const running = data.actualStartAt !== null && data.actualMinutes === null
const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned
return (
-
-
-
+ <>
{data.name}
@@ -116,7 +107,26 @@ function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
{fmt(data.availableToTransfer)} to transfer
)}
+ >
+ )
+}
+/**
+ * One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
+ * from the template at creation) and colored by its live status. Clicking it opens the stage
+ * drawer — handled by the page via `onNodeClick`, not here.
+ */
+function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
+ return (
+
+
+
)
diff --git a/Frontend/erp-system/components/settings/BranchMasterPage.tsx b/Frontend/erp-system/components/settings/BranchMasterPage.tsx
index b2781f4..d3876c0 100644
--- a/Frontend/erp-system/components/settings/BranchMasterPage.tsx
+++ b/Frontend/erp-system/components/settings/BranchMasterPage.tsx
@@ -8,7 +8,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
-import { Building2, ChevronLeft, ChevronRight, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
+import { Building2, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -22,6 +22,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -477,54 +478,13 @@ export function BranchMasterPage() {
)}
{pagination && pagination.totalItems > 0 && (
-
-
-
- Showing {(pagination.page - 1) * pagination.pageSize + 1} –
-
- {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
- {" "}
- of {pagination.totalItems} branches
-
-
- Rows per page
- { setPage(1); setPageSize(Number(e.target.value)) }}
- className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
- aria-label="Rows per page"
- >
- {PAGE_SIZE_OPTIONS.map((size) => (
- {size}
- ))}
-
-
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
-
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
- = pagination.totalPages}
- onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
- >
- Next
-
-
-
+
+
{ setPage(1); setPageSize(size) }}
+ />
)}
diff --git a/Frontend/erp-system/components/ui/otp-input.tsx b/Frontend/erp-system/components/ui/otp-input.tsx
index 1b542e3..c674825 100644
--- a/Frontend/erp-system/components/ui/otp-input.tsx
+++ b/Frontend/erp-system/components/ui/otp-input.tsx
@@ -38,7 +38,7 @@ export default function OtpInput({ length = 6, value = "", onChange }: OtpInputP
value={char}
onChange={(e) => handleChange(e.target.value, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
- className="w-14 h-14 mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30"
+ className="size-11 sm:size-14 mx-0.5 sm:mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30"
/>
)
}