Ui fixings
This commit is contained in:
@@ -4,8 +4,6 @@ import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
Inbox,
|
||||
@@ -33,6 +31,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -382,52 +381,13 @@ export default function HelpPage() {
|
||||
)}
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "}
|
||||
of <span className="font-medium text-foreground">{totalItems}</span> tickets
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
|
||||
import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
|
||||
@@ -337,6 +337,14 @@ export default function EmployeesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/hrm/employees/${e.employeeId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${e.fullName}`}
|
||||
title={`View ${e.fullName}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
@@ -392,7 +400,7 @@ export default function EmployeesPage() {
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
|
||||
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
|
||||
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -452,6 +460,9 @@ export default function EmployeesPage() {
|
||||
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
@@ -490,54 +501,13 @@ export default function EmployeesPage() {
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
|
||||
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => 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. */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Template</Label>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">Template</Label>
|
||||
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All templates" />
|
||||
@@ -111,8 +134,8 @@ export default function ProducedItemsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Warehouse</Label>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label className="text-sm sm:text-base">Warehouse</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
@@ -124,8 +147,8 @@ export default function ProducedItemsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-base">Status</Label>
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
|
||||
<Label className="text-sm sm:text-base">Status</Label>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue />
|
||||
@@ -175,67 +198,148 @@ export default function ProducedItemsPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((line) => (
|
||||
<TableRow
|
||||
key={line.runId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
<>
|
||||
{/* Mobile: totals first (the figure people come here for), then one card per line. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
|
||||
<p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
|
||||
<div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Produced</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Scrapped</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Good</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageItems.map((line) => (
|
||||
<button
|
||||
key={line.runId}
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{line.docNo}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Produced</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{line.producedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Scrapped</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{line.scrappedQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Good</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{line.goodQty.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unit cost</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{formatCost(line.unitCost)}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Cost of production</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{formatCost(line.totalCost)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="w-full truncate border-t pt-3 text-xs text-muted-foreground">
|
||||
{line.templateName} · {line.warehouseName}
|
||||
{line.completedAt && ` · Completed ${new Date(line.completedAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
||||
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
||||
Cost of Production: {formatCost(data.summary.totalCost)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((line) => (
|
||||
<TableRow
|
||||
key={line.runId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
||||
{line.status === "InProgress" ? "In Progress" : line.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
||||
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
||||
Cost of Production: {formatCost(data.summary.totalCost)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -140,7 +140,7 @@ export function RunActions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
|
||||
<Undo2 className="size-4" />
|
||||
Return leftover
|
||||
|
||||
@@ -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<number, RunStageData> {
|
||||
// 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<number | null>(
|
||||
(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<number, RunStageData>): { 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<number | null>(
|
||||
(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<number, RunStageData>()), [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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-full max-w-64" />
|
||||
<Skeleton className="h-40 w-full rounded-2xl" />
|
||||
<Skeleton className="h-[45vh] w-full rounded-2xl" />
|
||||
<Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -292,9 +333,38 @@ export default function ProductionRunDetailPage() {
|
||||
<StageStatusLegend />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">Click a stage to open it.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="md:hidden">Tap a stage to open it.</span>
|
||||
<span className="hidden md:inline">Click a stage to open it.</span>
|
||||
</p>
|
||||
|
||||
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{/* 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. */}
|
||||
<ol className="flex flex-col gap-3 md:hidden">
|
||||
{orderedStages.map((stage, i) => {
|
||||
const data = stageData.get(stage.runStageId)!
|
||||
return (
|
||||
<li key={stage.runStageId} className="flex gap-3">
|
||||
<span className="mt-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
>
|
||||
<RunStageCardBody data={data} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="hidden h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
|
||||
@@ -26,6 +26,9 @@ import { toast } from "@/components/ui/toast"
|
||||
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
|
||||
|
||||
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
|
||||
|
||||
type StatusFilter = ProductionRunStatus | "All"
|
||||
|
||||
@@ -57,6 +60,8 @@ export default function ProductionRunsPage() {
|
||||
const [templateId, setTemplateId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
@@ -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() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1>
|
||||
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p>
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Runs</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">All manufacturing runs with per-stage progress at a glance.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
|
||||
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Start a run</DialogTitle>
|
||||
@@ -266,54 +277,59 @@ export default function ProductionRunsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative lg:flex-1 lg:basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search 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"
|
||||
/>
|
||||
</div>
|
||||
<Select<number | null>
|
||||
value={templateId}
|
||||
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
|
||||
>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All templates" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
|
||||
>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
|
||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
||||
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:contents">
|
||||
<Select<number | null>
|
||||
value={templateId}
|
||||
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
|
||||
<SelectValue placeholder="All templates" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
|
||||
<SelectValue placeholder="All warehouses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
|
||||
<SelectTrigger className="col-span-2 h-12! w-full text-base sm:col-span-1 sm:h-14! lg:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
||||
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
@@ -426,7 +442,17 @@ export default function ProductionRunsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
{pagination && runs.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full md:hidden" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -233,7 +233,9 @@ export function StageEditorPanel({
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
|
||||
{/* 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. */}
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-h-[85vh] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stage editor</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -301,7 +303,7 @@ export function StageEditorPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<button type="button" onClick={() => removeInput(input.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove input">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
@@ -404,7 +406,7 @@ export function StageEditorPanel({
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<button type="button" onClick={() => removeOutput(output.key)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove output">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
@@ -462,7 +464,7 @@ export function StageEditorPanel({
|
||||
className="h-8 flex-1 text-sm"
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<button type="button" onClick={() => removeField(field.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove field">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -24,14 +24,15 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
|
||||
disconnected && "opacity-40"
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
|
||||
{/* Handles are finger-sized below md: dragging a connection starts from them. */}
|
||||
<Handle type="target" position={Position.Left} className="bg-primary! size-4! md:size-2.5!" />
|
||||
|
||||
{selected && data.onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete stage"
|
||||
title="Delete stage"
|
||||
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
|
||||
className="nodrag absolute -top-3 -right-3 flex size-7 items-center md:-top-2.5 md:-right-2.5 md:size-5 justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
data.onDelete?.()
|
||||
@@ -60,7 +61,7 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
|
||||
|
||||
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
|
||||
<Handle type="source" position={Position.Right} className="bg-primary! size-4! md:size-2.5!" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-10 w-full max-w-72" />
|
||||
<Skeleton className="h-[60vh] w-full rounded-2xl" />
|
||||
</div>
|
||||
)
|
||||
@@ -661,12 +661,15 @@ function TemplateBuilderContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1>
|
||||
// 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.
|
||||
<div className="flex flex-col gap-4 lg:h-[calc(100vh-8rem)]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h1 className="min-w-0 wrap-break-word text-xl font-bold text-foreground sm:text-2xl">{name || "Untitled template"}</h1>
|
||||
{graph ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -683,16 +686,17 @@ function TemplateBuilderContent() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
|
||||
{edges.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Two-up grid on phones so every action is a full-width tap target; a single row from sm. */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||
{!locked && (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={addStage}>
|
||||
<Button type="button" variant="outline" className="col-span-2 sm:col-span-1" onClick={addStage}>
|
||||
<Plus className="size-5" />
|
||||
Add Stage
|
||||
</Button>
|
||||
@@ -710,6 +714,7 @@ function TemplateBuilderContent() {
|
||||
<Button
|
||||
type="button"
|
||||
variant={graph.status === "Active" ? "destructive" : "success"}
|
||||
className={cn(locked && "col-span-2 sm:col-span-1")}
|
||||
onClick={handleToggleStatus}
|
||||
disabled={togglingStatus}
|
||||
>
|
||||
@@ -717,7 +722,7 @@ function TemplateBuilderContent() {
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<Button type="button" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Button type="button" className={cn(!graph && "col-span-2 sm:col-span-1")} onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : isNew ? "Create" : "Save"}
|
||||
</Button>
|
||||
@@ -725,7 +730,7 @@ function TemplateBuilderContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[10rem_1fr_1fr]">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-[10rem_1fr_1fr]">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
|
||||
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
|
||||
@@ -734,7 +739,7 @@ function TemplateBuilderContent() {
|
||||
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
|
||||
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
|
||||
</Field>
|
||||
<Field>
|
||||
<Field className="sm:col-span-2 lg:col-span-1">
|
||||
<FieldLabel htmlFor="tpl-description">Description</FieldLabel>
|
||||
<Input
|
||||
id="tpl-description"
|
||||
@@ -786,7 +791,7 @@ function TemplateBuilderContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
<div className="h-[65dvh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 lg:h-auto lg:min-h-0 lg:flex-1">
|
||||
{mounted && (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
@@ -806,7 +811,8 @@ function TemplateBuilderContent() {
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={!locked} />
|
||||
<MiniMap pannable zoomable />
|
||||
{/* Covers too much of a phone-sized canvas to be worth it. */}
|
||||
<MiniMap pannable zoomable className="hidden! md:block!" />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<StatusFilter>("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() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1>
|
||||
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p>
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Templates</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">Every production line, stage by stage. Tap a line to open its builder.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
|
||||
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New template</DialogTitle>
|
||||
@@ -253,7 +266,7 @@ export default function ProductionTemplatesPage() {
|
||||
)}
|
||||
|
||||
{templates === null ? (
|
||||
<Skeleton className="h-[70vh] min-h-105 w-full rounded-2xl" />
|
||||
<Skeleton className="h-64 w-full rounded-2xl md:h-[70vh] md:min-h-105" />
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<LayoutTemplate className="size-12 text-muted-foreground" />
|
||||
@@ -263,7 +276,62 @@ export default function ProductionTemplatesPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{templates.map((t) => (
|
||||
<Link
|
||||
key={t.templateId}
|
||||
href={`/dashboard/production/templates/${t.templateId}`}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm active:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">{t.code}</p>
|
||||
<p className="truncate font-semibold text-foreground">{t.name}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
{t.activeRunCount > 0 && (
|
||||
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
{t.activeRunCount} in progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{t.stageNames.length > 0 ? (
|
||||
<ol className="flex flex-col gap-1.5 border-t pt-3">
|
||||
{t.stageNames.map((stageName, i) => (
|
||||
<li key={i} className="flex items-center gap-2.5 text-sm">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary tabular-nums">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="truncate text-foreground">{stageName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="border-t pt-3 text-sm text-muted-foreground">No stages yet.</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{pagination && templates.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
|
||||
{mounted ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
@@ -287,7 +355,11 @@ export default function ProductionTemplatesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<ItemOverview[] | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [brands, setBrands] = useState<Brand[]>([])
|
||||
@@ -84,6 +86,7 @@ export default function ItemsPage() {
|
||||
const [categoryId, setCategoryId] = useState<number | "All">("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [sortKey, setSortKey] = useState<SortKey>("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() {
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && pagination.page < pagination.totalPages && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={showMore} disabled={loadingMore}>
|
||||
{loadingMore ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
@@ -516,12 +547,14 @@ export default function ItemsPage() {
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> roles
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
|
||||
<span className="text-muted-foreground">{u.roleName ?? "—"}</span>
|
||||
<div className="flex items-center gap-1" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${u.username}`}
|
||||
title={`View ${u.username}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
@@ -316,6 +324,14 @@ export default function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${u.username}`}
|
||||
title={`View ${u.username}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
@@ -352,54 +368,13 @@ export default function UsersPage() {
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalItems > 0 && (
|
||||
<div className="hidden items-center gap-3 md:flex md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> users
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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. */}
|
||||
<div className="mt-4 hidden flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between md:flex">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">{Math.min(page * pageSize, total)}</span>{" "}
|
||||
of <span className="font-medium text-foreground">{total}</span> {itemLabel}
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onPageChange(Math.min(totalPages, page + 1))}>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems: total, totalPages }}
|
||||
onPageChange={onPageChange}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -322,7 +295,6 @@ export default function WarehouseDetailPage() {
|
||||
total={bins.length}
|
||||
onPageChange={setBinsPage}
|
||||
onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }}
|
||||
itemLabel="bins"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="hidden flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between md:flex">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{pagination.totalItems}</span> warehouses
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { 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) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user