Ui fixings

This commit is contained in:
2026-09-24 12:30:21 +05:30
parent f5e8e0e5b9
commit 049c98298a
34 changed files with 1021 additions and 1247 deletions
@@ -4,8 +4,6 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import {
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Clock, Clock,
HelpCircle, HelpCircle,
Inbox, Inbox,
@@ -33,6 +31,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -382,52 +381,13 @@ export default function HelpPage() {
)} )}
{totalItems > 0 && ( {totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page, pageSize, totalItems, totalPages }}
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{totalItems}</span> tickets onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" 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 { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters" import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
@@ -337,6 +337,14 @@ export default function EmployeesPage() {
</div> </div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav> <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 <Link
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} 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">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</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-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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -452,6 +460,9 @@ export default function EmployeesPage() {
<TableCell className="px-3 py-4" data-no-row-nav> <TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <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}`}> <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" /> <Pencil className="size-4" />
</Link> </Link>
@@ -490,54 +501,13 @@ export default function EmployeesPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters" import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -456,54 +456,13 @@ export default function LeaveRequestsPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" 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 { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -328,52 +329,13 @@ export default function PayrollRunDetailPage() {
)} )}
{filteredLines.length > 0 && ( {filteredLines.length > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Plus } from "lucide-react" import { Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll" import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" 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 { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -238,54 +239,13 @@ export default function PayrollRunsPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -562,54 +562,13 @@ export default function DepartmentsPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -534,54 +534,13 @@ export default function DocumentTypesPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { leaveTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -559,54 +559,13 @@ export default function LeaveTypesPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { salaryComponentsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -527,54 +527,13 @@ export default function SalaryComponentsPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -629,54 +629,13 @@ export default function WorkShiftsPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -9,6 +9,7 @@ import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates" import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production" import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
@@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" 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 }) 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() { export default function ProducedItemsPage() {
const router = useRouter() const router = useRouter()
@@ -49,6 +55,8 @@ export default function ProducedItemsPage() {
const [templateId, setTemplateId] = useState<number | null>(null) const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const load = useCallback(() => { const load = useCallback(() => {
setLoadError(null) setLoadError(null)
@@ -60,7 +68,11 @@ export default function ProducedItemsPage() {
warehouseId: warehouseId ?? undefined, warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status, 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) => { .catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } }) setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
@@ -80,26 +92,37 @@ export default function ProducedItemsPage() {
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All" 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
<p className="text-base text-muted-foreground"> <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). Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> {/* Two-up on phones (date range side by side, then template/warehouse), status full width —
<div className="flex flex-col gap-1.5"> five stacked labelled controls would push the results well below the fold. */}
<Label className="text-base">From</Label> <div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<Input type="date" value={from} onChange={(e) => setFrom(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">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">To</Label> <Label className="text-sm sm:text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" /> <Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Template</Label> <Label className="text-sm sm:text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}> <Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" /> <SelectValue placeholder="All templates" />
@@ -111,8 +134,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Warehouse</Label> <Label className="text-sm sm:text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}> <Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" /> <SelectValue placeholder="All warehouses" />
@@ -124,8 +147,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
<Label className="text-base">Status</Label> <Label className="text-sm sm:text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> <Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue /> <SelectValue />
@@ -175,67 +198,148 @@ export default function ProducedItemsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border"> <>
<Table className="text-base"> {/* Mobile: totals first (the figure people come here for), then one card per line. */}
<TableHeader> <div className="flex flex-col gap-3 md:hidden">
<TableRow> <div className="rounded-xl border bg-card p-4 shadow-sm">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <p className="text-sm font-semibold text-foreground">
<TableHead className="h-12 px-3 text-sm">Status</TableHead> Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
<TableHead className="h-12 px-3 text-sm">Template</TableHead> </p>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
<TableHead className="h-12 px-3 text-sm">Item</TableHead> <p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead> <div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead> <div>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead> <p className="text-muted-foreground">Produced</p>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead> <p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead> </div>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead> <div>
</TableRow> <p className="text-muted-foreground">Scrapped</p>
</TableHeader> <p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
<TableBody> </div>
{data.items.map((line) => ( <div>
<TableRow <p className="text-muted-foreground">Good</p>
key={line.runId} <p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
className="cursor-pointer" </div>
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)} </div>
> </div>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5"> {pageItems.map((line) => (
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}> <button
{line.status === "InProgress" ? "In Progress" : line.status} key={line.runId}
</Badge> type="button"
</TableCell> onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell> className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell> >
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell> <div className="flex w-full items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell> <p className="truncate font-semibold text-foreground">{line.docNo}</p>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell> <p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell> </div>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell> <Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
<TableCell className="px-3 py-3.5"> {line.status === "InProgress" ? "In Progress" : line.status}
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"} </Badge>
</TableCell> </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> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
<TableFooter> {pageItems.map((line) => (
<TableRow> <TableRow
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}> key={line.runId}
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}) className="cursor-pointer"
</TableCell> onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
<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 font-medium text-foreground">{line.docNo}</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" /> <Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
<TableCell className="px-3 py-3.5 text-right font-semibold"> {line.status === "InProgress" ? "In Progress" : line.status}
Cost of Production: {formatCost(data.summary.totalCost)} </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5" /> <TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
</TableRow> <TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
</TableFooter> <TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
</Table> <TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
</div> <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> </div>
) )
@@ -140,7 +140,7 @@ export function RunActions({
return ( 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}> <Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
<Undo2 className="size-4" /> <Undo2 className="size-4" />
Return leftover Return leftover
@@ -19,6 +19,7 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors" import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import { import {
RunStageCardBody,
RunHeaderNodeComponent, RunHeaderNodeComponent,
RunStageNodeComponent, RunStageNodeComponent,
type RunHeaderData, type RunHeaderData,
@@ -50,7 +51,75 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
*/ */
const HEADER_GAP_X = 240 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 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 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) { for (const stage of run.stages) {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({ nodes.push({
id: String(stage.runStageId), id: String(stage.runStageId),
type: "runStage", type: "runStage",
position: { x: stage.posX, y: stage.posY }, position: { x: stage.posX, y: stage.posY },
data: { data: stageData.get(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,
draggable: false, 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( const { nodes, edges } = useMemo(
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }), () => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run] [run, stageData]
) )
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
const stageSummary: StageSummary = useMemo(() => { const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 } const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
@@ -213,9 +254,9 @@ export default function ProductionRunDetailPage() {
if (!run) { if (!run) {
return ( return (
<div className="flex flex-col gap-3"> <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-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> </div>
) )
} }
@@ -292,9 +333,38 @@ export default function ProductionRunDetailPage() {
<StageStatusLegend /> <StageStatusLegend />
</div> </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 ? ( {mounted ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -26,6 +26,9 @@ import { toast } from "@/components/ui/toast"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip" import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
const PAGE_SIZE = 25 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" type StatusFilter = ProductionRunStatus | "All"
@@ -57,6 +60,8 @@ export default function ProductionRunsPage() {
const [templateId, setTemplateId] = useState<number | null>(null) const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([]) const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -85,7 +90,7 @@ export default function ProductionRunsPage() {
productionRunsApi productionRunsApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
templateId: templateId ?? undefined, templateId: templateId ?? undefined,
@@ -99,10 +104,16 @@ export default function ProductionRunsPage() {
setRuns([]) setRuns([])
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
}) })
}, [page, query, status, templateId, warehouseId]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status, templateId, warehouseId])
useEffect(load, [load]) 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 // 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 // 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). // 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-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Runs</h1>
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p> <p className="text-sm text-muted-foreground sm:text-base">All manufacturing runs with per-stage progress at a glance.</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <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"> <DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Start a run</DialogTitle> <DialogTitle>Start a run</DialogTitle>
@@ -266,54 +277,59 @@ export default function ProductionRunsPage() {
</Dialog> </Dialog>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> {/* Search full width, then the three filters as a compact grid on phones (four stacked
<div className="relative flex-1 basis-0"> 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" /> <Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no…" 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" aria-label="Search runs"
/> />
</div> </div>
<Select<number | null> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:contents">
value={templateId} <Select<number | null>
onValueChange={(v) => { setTemplateId(v); setPage(1) }} 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 className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
</SelectTrigger> <SelectValue placeholder="All templates" />
<SelectContent> </SelectTrigger>
{templates.map((t) => ( <SelectContent>
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem> {templates.map((t) => (
))} <SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<Select<number | null> </Select>
value={warehouseId} <Select<number | null>
onValueChange={(v) => { setWarehouseId(v); setPage(1) }} 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 className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
</SelectTrigger> <SelectValue placeholder="All warehouses" />
<SelectContent> </SelectTrigger>
{warehouses.map((w) => ( <SelectContent>
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem> {warehouses.map((w) => (
))} <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}> </Select>
<SelectTrigger className="h-14! w-full sm:w-48 text-base"> <Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectValue /> <SelectTrigger className="col-span-2 h-12! w-full text-base sm:col-span-1 sm:h-14! lg:w-48">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value="All" className="text-base">All statuses</SelectItem> <SelectContent>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem> <SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem> <SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem> <SelectItem value="Completed" className="text-base">Completed</SelectItem>
</SelectContent> <SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</Select> </SelectContent>
</Select>
</div>
</div> </div>
{hasFilters && ( {hasFilters && (
@@ -426,7 +442,17 @@ export default function ProductionRunsPage() {
})} })}
</div> </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> </div>
@@ -233,7 +233,9 @@ export function StageEditorPanel({
return ( return (
<Dialog open onOpenChange={(next) => !next && onClose()}> <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> <DialogHeader>
<DialogTitle>Stage editor</DialogTitle> <DialogTitle>Stage editor</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -301,7 +303,7 @@ export function StageEditorPanel({
</SelectContent> </SelectContent>
</Select> </Select>
{!readOnly && ( {!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" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -404,7 +406,7 @@ export function StageEditorPanel({
/> />
)} )}
{!readOnly && ( {!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" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -462,7 +464,7 @@ export function StageEditorPanel({
className="h-8 flex-1 text-sm" className="h-8 flex-1 text-sm"
/> />
{!readOnly && ( {!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" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -24,14 +24,15 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
disconnected && "opacity-40" 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 && ( {selected && data.onDelete && (
<button <button
type="button" type="button"
aria-label="Delete stage" aria-label="Delete stage"
title="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) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
data.onDelete?.() 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>} {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> </div>
) )
} }
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
if (!loaded) { if (!loaded) {
return ( return (
<div className="flex flex-col gap-3"> <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" /> <Skeleton className="h-[60vh] w-full rounded-2xl" />
</div> </div>
) )
@@ -661,12 +661,15 @@ function TemplateBuilderContent() {
} }
return ( return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4"> // Phones stack naturally and scroll the page; the canvas gets its own fixed height below.
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> // Only from lg up does the builder fill the viewport with the canvas taking the remainder —
<div className="flex items-center gap-3"> // on a short screen the fields and issue list would otherwise squeeze it to nothing.
<div> <div className="flex flex-col gap-4 lg:h-[calc(100vh-8rem)]">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1> <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 ? ( {graph ? (
<Badge <Badge
variant="outline" variant="outline"
@@ -683,16 +686,17 @@ function TemplateBuilderContent() {
</Badge> </Badge>
)} )}
</div> </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 {stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
{edges.length === 1 ? "" : "s"} {edges.length === 1 ? "" : "s"}
</p> </p>
</div> </div>
</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 && ( {!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" /> <Plus className="size-5" />
Add Stage Add Stage
</Button> </Button>
@@ -710,6 +714,7 @@ function TemplateBuilderContent() {
<Button <Button
type="button" type="button"
variant={graph.status === "Active" ? "destructive" : "success"} variant={graph.status === "Active" ? "destructive" : "success"}
className={cn(locked && "col-span-2 sm:col-span-1")}
onClick={handleToggleStatus} onClick={handleToggleStatus}
disabled={togglingStatus} disabled={togglingStatus}
> >
@@ -717,7 +722,7 @@ function TemplateBuilderContent() {
</Button> </Button>
)} )}
{!locked && ( {!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" /> <Save className="size-5" />
{saving ? "Saving…" : isNew ? "Create" : "Save"} {saving ? "Saving…" : isNew ? "Create" : "Save"}
</Button> </Button>
@@ -725,7 +730,7 @@ function TemplateBuilderContent() {
</div> </div>
</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> <Field>
<FieldLabel htmlFor="tpl-code">Code</FieldLabel> <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" /> <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> <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" /> <Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
</Field> </Field>
<Field> <Field className="sm:col-span-2 lg:col-span-1">
<FieldLabel htmlFor="tpl-description">Description</FieldLabel> <FieldLabel htmlFor="tpl-description">Description</FieldLabel>
<Input <Input
id="tpl-description" id="tpl-description"
@@ -786,7 +791,7 @@ function TemplateBuilderContent() {
</div> </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 && ( {mounted && (
<ReactFlow <ReactFlow
nodes={displayNodes} nodes={displayNodes}
@@ -806,7 +811,8 @@ function TemplateBuilderContent() {
> >
<Background /> <Background />
<Controls showInteractive={!locked} /> <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> </ReactFlow>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react" import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
import "@xyflow/react/dist/style.css" 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 { productionTemplatesApi } from "@/lib/api/production-templates"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production" import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { import {
@@ -29,6 +31,9 @@ import { Skeleton } from "@/components/ui/skeleton"
type StatusFilter = TemplateStatus | "All" type StatusFilter = TemplateStatus | "All"
const PAGE_SIZE = 25 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 } const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
@@ -92,6 +97,8 @@ export default function ProductionTemplatesPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -119,7 +126,7 @@ export default function ProductionTemplatesPage() {
productionTemplatesApi productionTemplatesApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
}) })
@@ -131,10 +138,16 @@ export default function ProductionTemplatesPage() {
setTemplates([]) setTemplates([])
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
}) })
}, [page, query, status]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status])
useEffect(load, [load]) useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = query.length > 0 || status !== "All" const hasFilters = query.length > 0 || status !== "All"
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates]) 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-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Templates</h1>
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p> <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> </div>
<Dialog open={open} onOpenChange={setOpen}> <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"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New template</DialogTitle> <DialogTitle>New template</DialogTitle>
@@ -253,7 +266,7 @@ export default function ProductionTemplatesPage() {
)} )}
{templates === null ? ( {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 ? ( ) : templates.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <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" /> <LayoutTemplate className="size-12 text-muted-foreground" />
@@ -263,7 +276,62 @@ export default function ProductionTemplatesPage() {
</div> </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 ? ( {mounted ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -287,7 +355,11 @@ export default function ProductionTemplatesPage() {
)} )}
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -25,6 +25,7 @@ import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export" import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { EntityStatus, PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data" 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() { export default function ItemsPage() {
const router = useRouter() const router = useRouter()
const isMobile = useIsMobile()
const [items, setItems] = useState<ItemOverview[] | null>(null) const [items, setItems] = useState<ItemOverview[] | null>(null)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [brands, setBrands] = useState<Brand[]>([]) const [brands, setBrands] = useState<Brand[]>([])
@@ -84,6 +86,7 @@ export default function ItemsPage() {
const [categoryId, setCategoryId] = useState<number | "All">("All") const [categoryId, setCategoryId] = useState<number | "All">("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loadingMore, setLoadingMore] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set()) const [selected, setSelected] = useState<Set<number>>(new Set())
const [sortKey, setSortKey] = useState<SortKey>("sku") const [sortKey, setSortKey] = useState<SortKey>("sku")
@@ -95,10 +98,19 @@ export default function ItemsPage() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [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() { useEffect(() => {
setError(null) let cancelled = false
// Mobile "Show more" appends the next server page to what's already listed.
const append = isMobile && page > 1
itemsApi itemsApi
.overview({ .overview({
page, page,
@@ -108,14 +120,27 @@ export default function ItemsPage() {
categoryId: categoryId === "All" ? undefined : categoryId, categoryId: categoryId === "All" ? undefined : categoryId,
}) })
.then((res) => { .then((res) => {
setItems(res.items) if (cancelled) return
setError(null)
setItems((prev) => (append && prev ? [...prev, ...res.items] : res.items))
setPagination(res.pagination) 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(() => { useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
@@ -398,6 +423,12 @@ export default function ItemsPage() {
</Link> </Link>
</div> </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>
<div className="hidden overflow-x-auto rounded-xl border md:block"> <div className="hidden overflow-x-auto rounded-xl border md:block">
@@ -516,12 +547,14 @@ export default function ItemsPage() {
</div> </div>
{pagination && ( {pagination && (
<PaginationFooter <div className="hidden md:block">
pagination={pagination} <PaginationFooter
onPageChange={setPage} pagination={pagination}
pageSizeOptions={PAGE_SIZE_OPTIONS} onPageChange={setPage}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }} pageSizeOptions={PAGE_SIZE_OPTIONS}
/> onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)} )}
</> </>
)} )}
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" 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 { taxRatesApi } from "@/lib/api/tax-rates"
import { errorMessage } from "@/lib/error-map" 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 { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -345,39 +346,7 @@ export default function TaxRatesPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<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>
)}
</> </>
)} )}
</div> </div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" 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 { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -382,39 +382,11 @@ export default function RolesPage() {
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<p className="text-sm text-muted-foreground"> <PaginationFooter
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– pagination={pagination}
<span className="font-medium text-foreground"> onPageChange={setPage}
{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> </div>
)} )}
</div> </div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" 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 { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" 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"> <div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
<span className="text-muted-foreground">{u.roleName ?? "—"}</span> <span className="text-muted-foreground">{u.roleName ?? "—"}</span>
<div className="flex items-center gap-1" data-no-row-nav> <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 <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -316,6 +324,14 @@ export default function UsersPage() {
</TableCell> </TableCell>
<TableCell className="px-3 py-4" data-no-row-nav> <TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <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 <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -352,54 +368,13 @@ export default function UsersPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
</div> </div>
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation" 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 { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
@@ -23,6 +23,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" 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 /** 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. */ * full up front, so paging is done in-memory rather than via query params. */
function TablePager({ function TablePager({
page, pageSize, total, onPageChange, onPageSizeChange, itemLabel, page, pageSize, total, onPageChange, onPageSizeChange,
}: { }: {
page: number page: number
pageSize: number pageSize: number
total: number total: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void onPageSizeChange: (pageSize: number) => void
itemLabel: string
}) { }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize)) const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (total === 0) return null if (total === 0) return null
@@ -65,40 +65,13 @@ function TablePager({
)} )}
{/* Desktop: full pager. */} {/* 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="mt-4 hidden md:block">
<div className="flex flex-wrap items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page, pageSize, totalItems: total, totalPages }}
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>– onPageChange={onPageChange}
<span className="font-medium text-foreground">{Math.min(page * pageSize, total)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{total}</span> {itemLabel} onPageSizeChange={onPageSizeChange}
</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> </div>
</> </>
) )
@@ -322,7 +295,6 @@ export default function WarehouseDetailPage() {
total={bins.length} total={bins.length}
onPageChange={setBinsPage} onPageChange={setBinsPage}
onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }} onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }}
itemLabel="bins"
/> />
</div> </div>
)} )}
@@ -444,7 +416,6 @@ export default function WarehouseDetailPage() {
total={stockRows.length} total={stockRows.length}
onPageChange={setStockPage} onPageChange={setStockPage}
onPageSizeChange={(size) => { setStockPageSize(size); setStockPage(1) }} onPageSizeChange={(size) => { setStockPageSize(size); setStockPage(1) }}
itemLabel="items"
/> />
</> </>
)} )}
@@ -543,7 +514,6 @@ export default function WarehouseDetailPage() {
total={movementByItem.length} total={movementByItem.length}
onPageChange={setMovementPage} onPageChange={setMovementPage}
onPageSizeChange={(size) => { setMovementPageSize(size); setMovementPage(1) }} onPageSizeChange={(size) => { setMovementPageSize(size); setMovementPage(1) }}
itemLabel="items"
/> />
</> </>
)} )}
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" 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 { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -29,6 +29,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -509,54 +510,13 @@ export default function WarehousesPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {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="hidden md:block">
<div className="flex flex-wrap items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</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> </div>
)} )}
@@ -1,12 +1,15 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import OtpInput from "@/components/ui/otp-input" import OtpInput from "@/components/ui/otp-input"
import { Mail } from "lucide-react" import { Mail } from "lucide-react"
export default function ForgotOtpPage() { export default function ForgotOtpPage() {
const router = useRouter()
const [otp, setOtp] = useState("") const [otp, setOtp] = useState("")
const [secondsLeft, setSecondsLeft] = useState(60) const [secondsLeft, setSecondsLeft] = useState(60)
@@ -24,47 +27,54 @@ export default function ForgotOtpPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4"> <AuthShell>
<div className="w-full max-w-2xl rounded-xl bg-card p-8 shadow-lg border border-border"> <div className="flex flex-col items-center text-center">
<div className="flex flex-col items-center mb-8"> <div className="mb-4 rounded-full bg-primary/10 p-4">
<div className="bg-foreground/10 rounded-full p-4 mb-4"> <Mail className="size-6 text-primary" />
<Mail className="text-foreground size-6" />
</div>
<h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1>
</div>
<p className="text-sm text-muted-foreground text-center mb-8">We've sent a verification code to your email address. Enter it below to continue.</p>
<div className="space-y-6">
<div className="flex justify-center">
<OtpInput value={otp} onChange={(v) => setOtp(v)} />
</div>
<div className="text-center">
{canResend ? (
<button
onClick={(e) => {
e.preventDefault()
handleResend()
}}
className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
>
Didn't receive the code? Resend
</button>
) : (
<p className="text-sm text-muted-foreground">Resend code available in <span className="font-semibold text-foreground">{Math.max(0, secondsLeft)}s</span></p>
)}
</div>
<div className="flex gap-3">
<Link href="/login" className="flex-1">
<Button variant="outline" className="w-full h-12 rounded-full border-input text-foreground hover:bg-foreground/5">Back</Button>
</Link>
<Link href="/login/forgot/reset" className="flex-1">
<Button className="w-full h-12 rounded-full bg-foreground text-background hover:bg-foreground/90">Verify</Button>
</Link>
</div>
</div> </div>
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Verify your email</h1>
<p className="mt-3 text-base text-muted-foreground">
We&apos;ve sent a verification code to your email address. Enter it below to continue.
</p>
</div> </div>
</div>
<form
noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
router.push("/login/forgot/reset")
}}
>
<OtpInput value={otp} onChange={(v) => setOtp(v)} />
<div className="text-center">
{canResend ? (
<button
type="button"
onClick={handleResend}
className="text-sm font-medium text-primary transition-colors hover:text-primary/80"
>
Didn&apos;t receive the code? Resend
</button>
) : (
<p className="text-sm text-muted-foreground">
Resend code available in <span className="font-semibold text-foreground">{Math.max(0, secondsLeft)}s</span>
</p>
)}
</div>
<Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold">
Verify
</Button>
<div className="text-center">
<Link href="/login" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Back to sign in
</Link>
</div>
</form>
</AuthShell>
) )
} }
+48 -46
View File
@@ -1,7 +1,9 @@
"use client" "use client"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { validateEmail } from "@/lib/validations" import { validateEmail } from "@/lib/validations"
@@ -18,52 +20,52 @@ export default function ForgotEmailPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 px-4 py-8"> <AuthShell>
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-border"> <div className="text-center">
<div className="mb-8 text-center"> <p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="text-2xl font-bold text-foreground">Reset your password</h1> <h1 className="mt-1 text-3xl font-bold text-foreground">Reset your password</h1>
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p> <p className="mt-3 text-base text-muted-foreground">
</div> Enter the email address associated with your account and we&apos;ll send you a one-time code to verify your identity.
</p>
<div className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-semibold text-foreground mb-2">Email address</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => onEmailChange((e.target as HTMLInputElement).value)}
className="h-11 rounded-lg"
placeholder="you@example.com"
aria-invalid={emailError !== ""}
/>
{emailError !== "" && <p className="text-sm text-error mt-2 font-medium">{emailError}</p>}
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => router.push('/login')}
className="flex-1 h-12 rounded-full border-input text-foreground hover:bg-foreground/5"
>
Back
</Button>
<Button
className="flex-1 h-12 text-base font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
disabled={emailError !== ""}
onClick={() => {
if (email.length === 0) {
setEmailError('Email is required')
return
}
if (emailError === "") router.push('/login/forgot/otp')
}}
>
Send verification code
</Button>
</div>
</div>
</div> </div>
</div>
<form
noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
if (email.length === 0) {
setEmailError("Email is required")
return
}
if (emailError === "") router.push("/login/forgot/otp")
}}
>
<div>
<label htmlFor="email" className="block text-sm font-semibold text-foreground">Email Address</label>
<Input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
className="mt-2 h-11 rounded-lg px-4 text-base"
placeholder="you@example.com"
aria-invalid={emailError !== ""}
/>
{emailError !== "" && <p className="mt-2 text-sm font-medium text-destructive">{emailError}</p>}
</div>
<Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold" disabled={emailError !== ""}>
Send verification code
</Button>
<div className="text-center">
<Link href="/login" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Back to sign in
</Link>
</div>
</form>
</AuthShell>
) )
} }
@@ -1,7 +1,9 @@
"use client" "use client"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@@ -13,6 +15,17 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Check, Eye, EyeOff } from "lucide-react" import { Check, Eye, EyeOff } from "lucide-react"
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
}
export default function ForgotResetPage() { export default function ForgotResetPage() {
const router = useRouter() const router = useRouter()
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
@@ -28,115 +41,109 @@ export default function ForgotResetPage() {
const passwordsMatch = password === confirm && password.length > 0 const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
const PasswordRequirement = ({ met, label }: { met: boolean; label: string }) => (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4"> <AuthShell>
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-border"> <div className="text-center">
<div className="mb-8 text-center"> <p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="text-2xl font-bold text-foreground">Create a new password</h1> <h1 className="mt-1 text-3xl font-bold text-foreground">Create a new password</h1>
<p className="mt-2 text-sm text-muted-foreground">Choose a strong password to secure your account.</p> <p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
</div>
<div className="space-y-6">
<div>
<label htmlFor="password" className="block text-sm font-semibold text-foreground mb-2">New password</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword((e.target as HTMLInputElement).value)}
className="h-11 pr-11"
placeholder="Enter your new password"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-semibold text-foreground mb-2">Confirm password</label>
<div className="relative">
<Input
id="confirm"
type={showConfirm ? 'text' : 'password'}
value={confirm}
onChange={(e) => setConfirm((e.target as HTMLInputElement).value)}
className="h-11 pr-11"
placeholder="Confirm your new password"
/>
<button
type="button"
onClick={() => setShowConfirm((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showConfirm ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
<PasswordRequirement met={hasNumber} label="At least 1 number" />
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
</div>
{!passwordsMatch && confirm.length > 0 && (
<p className="text-sm text-error font-medium">Passwords don't match.</p>
)}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => router.push('/login')}
className="flex-1 h-12 rounded-full border-input text-foreground hover:bg-foreground/5"
>
Back
</Button>
<Button
disabled={!allValid}
onClick={() => setShowSuccess(true)}
className="flex-1 h-12 rounded-full bg-foreground text-background hover:bg-foreground/90"
>
Change password
</Button>
</div>
</div>
<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<DialogContent className="w-full max-w-lg sm:max-w-lg">
<DialogHeader className="text-center">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-success/10 text-success">
<Check className="size-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold">Password changed successfully</DialogTitle>
<DialogDescription className="mt-2">Your password has been updated. You can now sign in with your new password.</DialogDescription>
</div>
</div>
</DialogHeader>
<Button onClick={() => router.push('/login')} className="w-4/5 mx-auto h-12 mt-6 rounded-full bg-foreground text-background hover:bg-foreground/90">
Return to sign in
</Button>
</DialogContent>
</Dialog>
</div> </div>
</div>
<form
noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
if (allValid) setShowSuccess(true)
}}
>
<div>
<label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label>
<div className="relative mt-2">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Enter your new password"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-semibold text-foreground">Confirm Password</label>
<div className="relative mt-2">
<Input
id="confirm"
type={showConfirm ? "text" : "password"}
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Confirm your new password"
aria-invalid={!passwordsMatch && confirm.length > 0}
/>
<button
type="button"
onClick={() => setShowConfirm((s) => !s)}
aria-label={showConfirm ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{showConfirm ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
{!passwordsMatch && confirm.length > 0 && (
<p className="mt-2 text-sm font-medium text-destructive">Passwords don&apos;t match.</p>
)}
</div>
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
<PasswordRequirement met={hasNumber} label="At least 1 number" />
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
</div>
<Button type="submit" disabled={!allValid} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
Change password
</Button>
<div className="text-center">
<Link href="/login" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Back to sign in
</Link>
</div>
</form>
<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<DialogContent className="w-full max-w-lg sm:max-w-lg">
<DialogHeader className="text-center">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-success/10 text-success">
<Check className="size-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold">Password changed successfully</DialogTitle>
<DialogDescription className="mt-2">Your password has been updated. You can now sign in with your new password.</DialogDescription>
</div>
</div>
</DialogHeader>
<Button onClick={() => router.push("/login")} className="mx-auto mt-6 h-12 w-4/5 rounded-full text-base font-semibold">
Return to sign in
</Button>
</DialogContent>
</Dialog>
</AuthShell>
) )
} }
+3 -38
View File
@@ -1,7 +1,6 @@
"use client" "use client"
import { Suspense, useState } from "react" import { Suspense, useState } from "react"
import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
@@ -12,6 +11,7 @@ import { authApi } from "@/lib/api/auth"
import { setStoredUser } from "@/lib/auth-session" import { setStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -55,35 +55,7 @@ function LoginForm() {
}) })
return ( return (
<div className="relative min-h-screen overflow-hidden"> <AuthShell>
{/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
cutout (whithoutbackground.png) so there's no white scene box floating on the
dark panel. */}
<div className="absolute inset-y-0 left-[45%] right-0 bg-white dark:bg-background">
<Image
src="/images/loginerps.png"
alt="ERP Illustration"
fill
className="object-contain object-center dark:hidden"
priority
/>
<Image
src="/images/whithoutbackground.png"
alt="ERP Illustration"
fill
className="hidden object-contain object-center dark:block"
priority
/>
</div>
{/* Login panel — overlays left side, no hard border with the photo */}
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
<div className="mb-8 flex justify-center">
<Link href="/" className="inline-flex items-center">
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
</Link>
</div>
<div className="text-center"> <div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p> <p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Welcome back</h1> <h1 className="mt-1 text-3xl font-bold text-foreground">Welcome back</h1>
@@ -174,13 +146,6 @@ function LoginForm() {
<div id="form-errors" className="sr-only" aria-live="polite"> <div id="form-errors" className="sr-only" aria-live="polite">
{Object.values(form.formState.errors).length > 0 && "There are errors in the form."} {Object.values(form.formState.errors).length > 0 && "There are errors in the form."}
</div> </div>
</AuthShell>
<div className="mt-12 text-center">
<p className="text-sm text-muted-foreground">
Don&apos;t have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link>
</p>
</div>
</main>
</div>
) )
} }
@@ -22,7 +22,11 @@ import { Badge } from "@/components/ui/badge"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
@@ -86,6 +90,14 @@ const STOCK_TITLES: Record<string, string> = {
"/dashboard/stock/wastage/new": "Record Wastage", "/dashboard/stock/wastage/new": "Record Wastage",
} }
const PRODUCTION_TITLES: Record<string, string> = {
"/dashboard/production": "Production",
"/dashboard/production/templates": "Production Templates",
"/dashboard/production/templates/new": "New Template",
"/dashboard/production/runs": "Production Runs",
"/dashboard/production/produced": "Produced Items",
}
function titleFromPath(pathname: string) { function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales" if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
@@ -103,6 +115,10 @@ function titleFromPath(pathname: string) {
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer" if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count" if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
if (PRODUCTION_TITLES[pathname]) return PRODUCTION_TITLES[pathname]
if (/^\/dashboard\/production\/templates\/[^/]+$/.test(pathname)) return "Template Builder"
if (/^\/dashboard\/production\/runs\/[^/]+$/.test(pathname)) return "Production Run"
if (PROCUREMENT_TITLES[pathname]) return PROCUREMENT_TITLES[pathname] if (PROCUREMENT_TITLES[pathname]) return PROCUREMENT_TITLES[pathname]
if (/^\/dashboard\/procurement\/requisitions\/[^/]+$/.test(pathname)) return "Requisition" if (/^\/dashboard\/procurement\/requisitions\/[^/]+$/.test(pathname)) return "Requisition"
if (/^\/dashboard\/procurement\/rfqs\/[^/]+$/.test(pathname)) return "RFQ" if (/^\/dashboard\/procurement\/rfqs\/[^/]+$/.test(pathname)) return "RFQ"
@@ -277,12 +293,16 @@ export function Header() {
type="button" type="button"
onClick={() => router.back()} onClick={() => router.back()}
aria-label="Go back" aria-label="Go back"
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground" className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground sm:size-9"
> >
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</button> </button>
)} )}
<h1 className="min-w-0 truncate text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1> {/* Phones wrap to two lines rather than truncating — at this width a one-line title
is cut to a few letters ("Prod…"). One truncated line from sm, where it fits. */}
<h1 className="line-clamp-2 min-w-0 text-base leading-tight font-bold tracking-tight wrap-break-word text-foreground sm:truncate sm:text-xl sm:leading-normal">
{title}
</h1>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3"> <div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
@@ -377,7 +397,8 @@ export function Header() {
)} )}
</div> </div>
<ThemeToggle /> {/* 108px wide — on phones it moves into the account menu so the title has room. */}
<ThemeToggle className="hidden sm:flex" />
<Popover onOpenChange={(open) => open && loadNotifications()}> <Popover onOpenChange={(open) => open && loadNotifications()}>
<PopoverTrigger <PopoverTrigger
@@ -443,7 +464,7 @@ export function Header() {
</Popover> </Popover>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted"> <DropdownMenuTrigger className="flex items-center gap-2 rounded-full p-1 outline-none hover:bg-muted sm:pr-2">
<Avatar> <Avatar>
<AvatarFallback className="bg-primary/10 font-semibold text-primary"> <AvatarFallback className="bg-primary/10 font-semibold text-primary">
{user ? initials(displayName(user)) : "?"} {user ? initials(displayName(user)) : "?"}
@@ -466,6 +487,39 @@ export function Header() {
</p> </p>
)} )}
</div> </div>
<div className="flex items-center justify-between gap-3 px-2 pb-2.5 sm:hidden">
<span className="text-sm text-muted-foreground">Theme</span>
<ThemeToggle />
</div>
{/* The header's branch picker is hidden on phones, so admins switch here. Radio
items rather than a nested Select: a Select's portaled popup would read as an
outside press and close this menu. */}
{branchingEnabled && isAdmin && (
<div className="sm:hidden">
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 text-sm">
<Building2 className="size-3.5" />
Branch
</DropdownMenuLabel>
<div className="max-h-48 overflow-y-auto">
<DropdownMenuRadioGroup
value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES}
onValueChange={(value: string) => handleBranchChange(value)}
>
<DropdownMenuRadioItem value={ALL_BRANCHES} closeOnClick className="py-2 text-base">
All Branches
</DropdownMenuRadioItem>
{branches.map((b) => (
<DropdownMenuRadioItem key={b.branchId} value={String(b.branchId)} closeOnClick className="py-2 text-base">
{b.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</div>
</DropdownMenuGroup>
</div>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
render={<Link href="/dashboard/profile" />} render={<Link href="/dashboard/profile" />}
@@ -0,0 +1,40 @@
import Image from "next/image"
import Link from "next/link"
/** Sign-in page chrome shared by the auth screens: illustration on the right, the form panel
* (logo + content) overlaying the left 45%. */
export function AuthShell({ children }: { children: React.ReactNode }) {
return (
<div className="relative min-h-screen overflow-hidden">
{/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
cutout (whithoutbackground.png) so there's no white scene box floating on the
dark panel. */}
<div className="absolute inset-y-0 left-[45%] right-0 bg-white dark:bg-background">
<Image
src="/images/loginerps.png"
alt="ERP Illustration"
fill
className="object-contain object-center dark:hidden"
priority
/>
<Image
src="/images/whithoutbackground.png"
alt="ERP Illustration"
fill
className="hidden object-contain object-center dark:block"
priority
/>
</div>
{/* Form panel — overlays left side, no hard border with the photo */}
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
<div className="mb-8 flex justify-center">
<Link href="/" className="inline-flex items-center">
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
</Link>
</div>
{children}
</main>
</div>
)
}
@@ -4,7 +4,7 @@
// (Branch, Designation, EmploymentType) — identical shape to each other, so one // (Branch, Designation, EmploymentType) — identical shape to each other, so one
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages. // component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -507,54 +508,13 @@ export function CodeNameMasterPage<T extends CodeNamed>({
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> {title.toLowerCase()}
</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> </div>
)} )}
</div> </div>
@@ -52,25 +52,16 @@ function fmt(n: number): string {
} }
/** /**
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied * The body of a stage card — everything but the frame. Shared by the canvas node below and the
* from the template at creation) and colored by its live status. Clicking it opens the stage * run page's phone list, so the two views of a stage can't drift apart.
* drawer — handled by the page via `onNodeClick`, not here.
*/ */
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) { export function RunStageCardBody({ data }: { data: RunStageData }) {
const color = STAGE_STATUS_COLOR[data.state] const color = STAGE_STATUS_COLOR[data.state]
const running = data.actualStartAt !== null && data.actualMinutes === null const running = data.actualStartAt !== null && data.actualMinutes === null
const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned
return ( return (
<div <>
className={cn(
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
selected ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${color}` } : undefined}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<div className="flex items-start gap-1.5"> <div className="flex items-start gap-1.5">
<span className="mt-1 size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} /> <span className="mt-1 size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<p className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{data.name}</p> <p className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{data.name}</p>
@@ -116,7 +107,26 @@ function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
{fmt(data.availableToTransfer)} to transfer {fmt(data.availableToTransfer)} to transfer
</div> </div>
)} )}
</>
)
}
/**
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
* from the template at creation) and colored by its live status. Clicking it opens the stage
* drawer — handled by the page via `onNodeClick`, not here.
*/
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
return (
<div
className={cn(
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
selected ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<RunStageCardBody data={data} />
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" /> <Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div> </div>
) )
@@ -8,7 +8,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { Building2, ChevronLeft, ChevronRight, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react" import { Building2, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -22,6 +22,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -477,54 +478,13 @@ export function BranchMasterPage() {
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> branches
</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> </div>
)} )}
</div> </div>
@@ -38,7 +38,7 @@ export default function OtpInput({ length = 6, value = "", onChange }: OtpInputP
value={char} value={char}
onChange={(e) => handleChange(e.target.value, i)} onChange={(e) => handleChange(e.target.value, i)}
onKeyDown={(e) => handleKeyDown(e, i)} onKeyDown={(e) => handleKeyDown(e, i)}
className="w-14 h-14 mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30" className="size-11 sm:size-14 mx-0.5 sm:mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30"
/> />
) )
} }