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 {
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
HelpCircle,
Inbox,
@@ -33,6 +31,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -382,52 +381,13 @@ export default function HelpPage() {
)}
{totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "}
of <span className="font-medium text-foreground">{totalItems}</span> tickets
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
@@ -337,6 +337,14 @@ export default function EmployeesPage() {
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${e.fullName}`}
title={`View ${e.fullName}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -392,7 +400,7 @@ export default function EmployeesPage() {
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead>
<TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -452,6 +460,9 @@ export default function EmployeesPage() {
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" />
</Link>
@@ -490,54 +501,13 @@ export default function EmployeesPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -456,54 +456,13 @@ export default function LeaveRequestsPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { BadgeDollarSign, ChevronLeft, ChevronRight, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -328,52 +329,13 @@ export default function PayrollRunDetailPage() {
)}
{filteredLines.length > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "}
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
import { Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map"
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -238,54 +239,13 @@ export default function PayrollRunsPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -562,54 +562,13 @@ export default function DepartmentsPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -534,54 +534,13 @@ export default function DocumentTypesPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -559,54 +559,13 @@ export default function LeaveTypesPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { salaryComponentsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -527,54 +527,13 @@ export default function SalaryComponentsPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -629,54 +629,13 @@ export default function WorkShiftsPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -9,6 +9,7 @@ import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data"
@@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -34,6 +36,10 @@ function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
// The produced list comes back whole (no server paging), so pages are sliced client-side from
// the already-loaded lines. The summary totals still cover the whole filtered list.
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
export default function ProducedItemsPage() {
const router = useRouter()
@@ -49,6 +55,8 @@ export default function ProducedItemsPage() {
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const load = useCallback(() => {
setLoadError(null)
@@ -60,7 +68,11 @@ export default function ProducedItemsPage() {
warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status,
})
.then(setData)
.then((res) => {
setData(res)
// A new filter is a new list — start back at the first page.
setPage(1)
})
.catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err))
@@ -80,26 +92,37 @@ export default function ProducedItemsPage() {
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
const totalItems = data?.items.length ?? 0
const pagination: PaginationMeta = {
page,
pageSize,
totalItems,
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
}
const pageItems = data?.items.slice((page - 1) * pageSize, page * pageSize) ?? []
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
<p className="text-base text-muted-foreground">
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
{/* Two-up on phones (date range side by side, then template/warehouse), status full width —
five stacked labelled controls would push the results well below the fold. */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Template</Label>
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" />
@@ -111,8 +134,8 @@ export default function ProducedItemsPage() {
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" />
@@ -124,8 +147,8 @@ export default function ProducedItemsPage() {
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Status</Label>
<div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
<Label className="text-sm sm:text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
@@ -175,67 +198,148 @@ export default function ProducedItemsPage() {
</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((line) => (
<TableRow
key={line.runId}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
<>
{/* Mobile: totals first (the figure people come here for), then one card per line. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<p className="text-sm font-semibold text-foreground">
Total · {data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"}
</p>
<p className="mt-1 text-xs text-muted-foreground">Cost of production</p>
<p className="text-xl font-bold text-foreground tabular-nums">{formatCost(data.summary.totalCost)}</p>
<div className="mt-3 grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Produced</p>
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalProducedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Scrapped</p>
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalScrappedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Good</p>
<p className="font-semibold text-foreground tabular-nums">{data.summary.totalGoodQty.toLocaleString()}</p>
</div>
</div>
</div>
{pageItems.map((line) => (
<button
key={line.runId}
type="button"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-left shadow-sm active:bg-muted/50"
>
<div className="flex w-full items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{line.docNo}</p>
<p className="truncate text-sm text-muted-foreground">{line.itemName}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</div>
<div className="grid w-full grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Produced</p>
<p className="font-medium text-foreground tabular-nums">{line.producedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Scrapped</p>
<p className="font-medium text-foreground tabular-nums">{line.scrappedQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Good</p>
<p className="font-medium text-foreground tabular-nums">{line.goodQty.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Unit cost</p>
<p className="font-medium text-foreground tabular-nums">{formatCost(line.unitCost)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Cost of production</p>
<p className="font-semibold text-foreground tabular-nums">{formatCost(line.totalCost)}</p>
</div>
</div>
<p className="w-full truncate border-t pt-3 text-xs text-muted-foreground">
{line.templateName} · {line.warehouseName}
{line.completedAt && ` · Completed ${new Date(line.completedAt).toLocaleDateString()}`}
</p>
</button>
))}
</div>
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
</TableHeader>
<TableBody>
{pageItems.map((line) => (
<TableRow
key={line.runId}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</>
)}
</div>
)
@@ -140,7 +140,7 @@ export function RunActions({
return (
<>
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
<Undo2 className="size-4" />
Return leftover
@@ -19,6 +19,7 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import {
RunStageCardBody,
RunHeaderNodeComponent,
RunStageNodeComponent,
type RunHeaderData,
@@ -50,7 +51,75 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
*/
const HEADER_GAP_X = 240
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
/** Card data per stage, keyed by runStageId — shared by the canvas nodes and the phone list. */
function buildStageData(run: ProductionRunGraph): Map<number, RunStageData> {
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const
const activeId =
run.status === "InProgress"
? actionable.reduce<number | null>(
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
null
)
: null
return new Map(
run.stages.map((stage) => {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
return [
stage.runStageId,
{
name: stage.name,
roleLabel: stage.roleLabel,
state: stage.status,
isTerminal: stage.isTerminal,
isEntry: stage.isEntry,
estimatedMinutes: stage.estimatedMinutes,
actualMinutes: stage.actualMinutes,
actualStartAt: stage.actualStartAt,
intake:
upstream.length === 0
? null
: {
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
},
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId,
} satisfies RunStageData,
]
})
)
}
/**
* Stages in flow order for the phone list: a topological sort, breaking ties by canvas position
* (left to right, then top to bottom) so parallel branches read the way they were drawn.
*/
function flowOrder(run: ProductionRunGraph): RunStage[] {
const byPosition = (a: RunStage, b: RunStage) => a.posX - b.posX || a.posY - b.posY
const inDegree = new Map(run.stages.map((s) => [s.runStageId, 0]))
for (const e of run.edges) inDegree.set(e.childRunStageId, (inDegree.get(e.childRunStageId) ?? 0) + 1)
const ready = run.stages.filter((s) => inDegree.get(s.runStageId) === 0)
const ordered: RunStage[] = []
while (ready.length > 0) {
ready.sort(byPosition)
const stage = ready.shift()!
ordered.push(stage)
for (const e of run.edges.filter((e) => e.parentRunStageId === stage.runStageId)) {
const next = (inDegree.get(e.childRunStageId) ?? 0) - 1
inDegree.set(e.childRunStageId, next)
if (next === 0) ready.push(run.stages.find((s) => s.runStageId === e.childRunStageId)!)
}
}
// The server guarantees a DAG, but never drop a stage from view if that ever fails to hold.
const seen = new Set(ordered.map((s) => s.runStageId))
return [...ordered, ...run.stages.filter((s) => !seen.has(s.runStageId)).sort(byPosition)]
}
function buildFlow(run: ProductionRunGraph, stageData: Map<number, RunStageData>): { nodes: Node[]; edges: Edge[] } {
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
@@ -64,42 +133,12 @@ function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
},
]
// The stage the operator is expected to act on: the furthest-along actionable one, so a run
// mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const
const activeId =
run.status === "InProgress"
? actionable.reduce<number | null>(
(found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null,
null
)
: null
for (const stage of run.stages) {
const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({
id: String(stage.runStageId),
type: "runStage",
position: { x: stage.posX, y: stage.posY },
data: {
name: stage.name,
roleLabel: stage.roleLabel,
state: stage.status,
isTerminal: stage.isTerminal,
isEntry: stage.isEntry,
estimatedMinutes: stage.estimatedMinutes,
actualMinutes: stage.actualMinutes,
actualStartAt: stage.actualStartAt,
intake:
upstream.length === 0
? null
: {
delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0),
planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0),
},
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId,
} satisfies RunStageData,
data: stageData.get(stage.runStageId)!,
draggable: false,
})
}
@@ -173,10 +212,12 @@ export default function ProductionRunDetailPage() {
})
}, [])
const stageData = useMemo(() => (run ? buildStageData(run) : new Map<number, RunStageData>()), [run])
const { nodes, edges } = useMemo(
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run]
() => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run, stageData]
)
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
@@ -213,9 +254,9 @@ export default function ProductionRunDetailPage() {
if (!run) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-full max-w-64" />
<Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-[45vh] w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
</div>
)
}
@@ -292,9 +333,38 @@ export default function ProductionRunDetailPage() {
<StageStatusLegend />
</div>
<p className="text-sm text-muted-foreground">Click a stage to open it.</p>
<p className="text-sm text-muted-foreground">
<span className="md:hidden">Tap a stage to open it.</span>
<span className="hidden md:inline">Click a stage to open it.</span>
</p>
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{/* Phones get the stages as a list in flow order — the operator on the floor needs to
reach "their" stage in one tap, not pan and pinch around a canvas. */}
<ol className="flex flex-col gap-3 md:hidden">
{orderedStages.map((stage, i) => {
const data = stageData.get(stage.runStageId)!
return (
<li key={stage.runStageId} className="flex gap-3">
<span className="mt-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground tabular-nums">
{i + 1}
</span>
<button
type="button"
onClick={() => setSelectedStageId(stage.runStageId)}
className={cn(
"min-w-0 flex-1 rounded-2xl bg-card p-3 text-left shadow-sm ring-2 transition-colors active:bg-muted/50",
selectedStageId === stage.runStageId ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && selectedStageId !== stage.runStageId ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
>
<RunStageCardBody data={data} />
</button>
</li>
)
})}
</ol>
<div className="hidden h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
{mounted ? (
<ReactFlow
nodes={nodes}
@@ -26,6 +26,9 @@ import { toast } from "@/components/ui/toast"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
const PAGE_SIZE = 25
// Mobile has no pagination — tapping "Show more" just grows the page size and re-fetches
// from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type StatusFilter = ProductionRunStatus | "All"
@@ -57,6 +60,8 @@ export default function ProductionRunsPage() {
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -85,7 +90,7 @@ export default function ProductionRunsPage() {
productionRunsApi
.list({
page,
pageSize: PAGE_SIZE,
pageSize,
q: query || undefined,
status: status === "All" ? undefined : status,
templateId: templateId ?? undefined,
@@ -99,10 +104,16 @@ export default function ProductionRunsPage() {
setRuns([])
setLoadError(errorMessage(err))
})
}, [page, query, status, templateId, warehouseId])
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status, templateId, warehouseId])
useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
// Inactive template that still has historical runs; the start dialog narrows to Active
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
@@ -172,11 +183,11 @@ export default function ProductionRunsPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1>
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Runs</h1>
<p className="text-sm text-muted-foreground sm:text-base">All manufacturing runs with per-stage progress at a glance.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Start a run</DialogTitle>
@@ -266,54 +277,59 @@ export default function ProductionRunsPage() {
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
{/* Search full width, then the three filters as a compact grid on phones (four stacked
56px controls pushed the runs below the fold); one row from lg, where `lg:contents`
lifts the selects into the outer flex row. */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative lg:flex-1 lg:basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no…"
className="h-14 w-full pl-11 text-base"
className="h-12 w-full pl-11 text-base sm:h-14"
aria-label="Search runs"
/>
</div>
<Select<number | null>
value={templateId}
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null>
value={warehouseId}
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:contents">
<Select<number | null>
value={templateId}
onValueChange={(v) => { setTemplateId(v); setPage(1) }}
>
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null>
value={warehouseId}
onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
>
<SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectTrigger className="col-span-2 h-12! w-full text-base sm:col-span-1 sm:h-14! lg:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{hasFilters && (
@@ -426,7 +442,17 @@ export default function ProductionRunsPage() {
})}
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
{pagination && runs.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full md:hidden" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</>
)}
</div>
@@ -233,7 +233,9 @@ export function StageEditorPanel({
return (
<Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
{/* No bare `max-w-lg`: it would override the base `max-w-[calc(100%-2rem)]` and run the
dialog edge to edge on a phone. dvh so mobile browser chrome doesn't hide the bottom. */}
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-h-[85vh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>Stage editor</DialogTitle>
</DialogHeader>
@@ -301,7 +303,7 @@ export function StageEditorPanel({
</SelectContent>
</Select>
{!readOnly && (
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<button type="button" onClick={() => removeInput(input.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<Trash2 className="size-4" />
</button>
)}
@@ -404,7 +406,7 @@ export function StageEditorPanel({
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<button type="button" onClick={() => removeOutput(output.key)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" />
</button>
)}
@@ -462,7 +464,7 @@ export function StageEditorPanel({
className="h-8 flex-1 text-sm"
/>
{!readOnly && (
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<button type="button" onClick={() => removeField(field.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<Trash2 className="size-4" />
</button>
)}
@@ -24,14 +24,15 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
disconnected && "opacity-40"
)}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
{/* Handles are finger-sized below md: dragging a connection starts from them. */}
<Handle type="target" position={Position.Left} className="bg-primary! size-4! md:size-2.5!" />
{selected && data.onDelete && (
<button
type="button"
aria-label="Delete stage"
title="Delete stage"
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
className="nodrag absolute -top-3 -right-3 flex size-7 items-center md:-top-2.5 md:-right-2.5 md:size-5 justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
onClick={(e) => {
e.stopPropagation()
data.onDelete?.()
@@ -60,7 +61,7 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
<Handle type="source" position={Position.Right} className="bg-primary! size-4! md:size-2.5!" />
</div>
)
}
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
if (!loaded) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-10 w-full max-w-72" />
<Skeleton className="h-[60vh] w-full rounded-2xl" />
</div>
)
@@ -661,12 +661,15 @@ function TemplateBuilderContent() {
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1>
// Phones stack naturally and scroll the page; the canvas gets its own fixed height below.
// Only from lg up does the builder fill the viewport with the canvas taking the remainder —
// on a short screen the fields and issue list would otherwise squeeze it to nothing.
<div className="flex flex-col gap-4 lg:h-[calc(100vh-8rem)]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h1 className="min-w-0 wrap-break-word text-xl font-bold text-foreground sm:text-2xl">{name || "Untitled template"}</h1>
{graph ? (
<Badge
variant="outline"
@@ -683,16 +686,17 @@ function TemplateBuilderContent() {
</Badge>
)}
</div>
<p className="text-base text-muted-foreground">
<p className="text-sm text-muted-foreground sm:text-base">
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
{edges.length === 1 ? "" : "s"}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Two-up grid on phones so every action is a full-width tap target; a single row from sm. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
{!locked && (
<>
<Button type="button" variant="outline" onClick={addStage}>
<Button type="button" variant="outline" className="col-span-2 sm:col-span-1" onClick={addStage}>
<Plus className="size-5" />
Add Stage
</Button>
@@ -710,6 +714,7 @@ function TemplateBuilderContent() {
<Button
type="button"
variant={graph.status === "Active" ? "destructive" : "success"}
className={cn(locked && "col-span-2 sm:col-span-1")}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
@@ -717,7 +722,7 @@ function TemplateBuilderContent() {
</Button>
)}
{!locked && (
<Button type="button" onClick={handleSave} disabled={saving || conflict}>
<Button type="button" className={cn(!graph && "col-span-2 sm:col-span-1")} onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : isNew ? "Create" : "Save"}
</Button>
@@ -725,7 +730,7 @@ function TemplateBuilderContent() {
</div>
</div>
<div className="grid gap-3 sm:grid-cols-[10rem_1fr_1fr]">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-[10rem_1fr_1fr]">
<Field>
<FieldLabel htmlFor="tpl-code">Code</FieldLabel>
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
@@ -734,7 +739,7 @@ function TemplateBuilderContent() {
<FieldLabel htmlFor="tpl-name">Name</FieldLabel>
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
</Field>
<Field>
<Field className="sm:col-span-2 lg:col-span-1">
<FieldLabel htmlFor="tpl-description">Description</FieldLabel>
<Input
id="tpl-description"
@@ -786,7 +791,7 @@ function TemplateBuilderContent() {
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
<div className="h-[65dvh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 lg:h-auto lg:min-h-0 lg:flex-1">
{mounted && (
<ReactFlow
nodes={displayNodes}
@@ -806,7 +811,8 @@ function TemplateBuilderContent() {
>
<Background />
<Controls showInteractive={!locked} />
<MiniMap pannable zoomable />
{/* Covers too much of a phone-sized canvas to be worth it. */}
<MiniMap pannable zoomable className="hidden! md:block!" />
</ReactFlow>
)}
</div>
@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
import "@xyflow/react/dist/style.css"
@@ -9,6 +10,7 @@ import { LayoutTemplate, Plus, Search } from "lucide-react"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
import { PaginationMeta } from "@/types/common"
import {
@@ -29,6 +31,9 @@ import { Skeleton } from "@/components/ui/skeleton"
type StatusFilter = TemplateStatus | "All"
const PAGE_SIZE = 25
// Mobile has no pagination — tapping "Show more" just grows the page size and re-fetches
// from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
@@ -92,6 +97,8 @@ export default function ProductionTemplatesPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
@@ -119,7 +126,7 @@ export default function ProductionTemplatesPage() {
productionTemplatesApi
.list({
page,
pageSize: PAGE_SIZE,
pageSize,
q: query || undefined,
status: status === "All" ? undefined : status,
})
@@ -131,10 +138,16 @@ export default function ProductionTemplatesPage() {
setTemplates([])
setLoadError(errorMessage(err))
})
}, [page, query, status])
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status])
useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = query.length > 0 || status !== "All"
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
@@ -174,11 +187,11 @@ export default function ProductionTemplatesPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1>
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Templates</h1>
<p className="text-sm text-muted-foreground sm:text-base">Every production line, stage by stage. Tap a line to open its builder.</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
<DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New template</DialogTitle>
@@ -253,7 +266,7 @@ export default function ProductionTemplatesPage() {
)}
{templates === null ? (
<Skeleton className="h-[70vh] min-h-105 w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl md:h-[70vh] md:min-h-105" />
) : templates.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<LayoutTemplate className="size-12 text-muted-foreground" />
@@ -263,7 +276,62 @@ export default function ProductionTemplatesPage() {
</div>
) : (
<>
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
{/* Phones get a list: a pan/zoom canvas of wide horizontal lines is unusable on a
narrow touch screen, so each line reads top to bottom instead. */}
<div className="flex flex-col gap-3 md:hidden">
{templates.map((t) => (
<Link
key={t.templateId}
href={`/dashboard/production/templates/${t.templateId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm active:bg-muted/50"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-muted-foreground">{t.code}</p>
<p className="truncate font-semibold text-foreground">{t.name}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</span>
{t.activeRunCount > 0 && (
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
{t.activeRunCount} in progress
</span>
)}
</div>
</div>
{t.stageNames.length > 0 ? (
<ol className="flex flex-col gap-1.5 border-t pt-3">
{t.stageNames.map((stageName, i) => (
<li key={i} className="flex items-center gap-2.5 text-sm">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary tabular-nums">
{i + 1}
</span>
<span className="truncate text-foreground">{stageName}</span>
</li>
))}
</ol>
) : (
<p className="border-t pt-3 text-sm text-muted-foreground">No stages yet.</p>
)}
</Link>
))}
{pagination && templates.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
<div className="hidden h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
{mounted ? (
<ReactFlow
nodes={nodes}
@@ -287,7 +355,11 @@ export default function ProductionTemplatesPage() {
)}
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</>
)}
</div>
@@ -25,6 +25,7 @@ import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
@@ -71,6 +72,7 @@ function SortableHeader({ label, active, order, onClick }: { label: string; acti
export default function ItemsPage() {
const router = useRouter()
const isMobile = useIsMobile()
const [items, setItems] = useState<ItemOverview[] | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [brands, setBrands] = useState<Brand[]>([])
@@ -84,6 +86,7 @@ export default function ItemsPage() {
const [categoryId, setCategoryId] = useState<number | "All">("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loadingMore, setLoadingMore] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [sortKey, setSortKey] = useState<SortKey>("sku")
@@ -95,10 +98,19 @@ export default function ItemsPage() {
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, categoryId])
// A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
// pages) — restarts at page 1. Reset during render rather than in an effect.
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
const [prevResetKey, setPrevResetKey] = useState(resetKey)
if (prevResetKey !== resetKey) {
setPrevResetKey(resetKey)
setPage(1)
}
function load() {
setError(null)
useEffect(() => {
let cancelled = false
// Mobile "Show more" appends the next server page to what's already listed.
const append = isMobile && page > 1
itemsApi
.overview({
page,
@@ -108,14 +120,27 @@ export default function ItemsPage() {
categoryId: categoryId === "All" ? undefined : categoryId,
})
.then((res) => {
setItems(res.items)
if (cancelled) return
setError(null)
setItems((prev) => (append && prev ? [...prev, ...res.items] : res.items))
setPagination(res.pagination)
setSelected(new Set())
if (!append) setSelected(new Set())
})
.catch((err) => setError(errorMessage(err)))
}
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => {
if (!cancelled) setLoadingMore(false)
})
return () => {
cancelled = true
}
}, [page, pageSize, query, status, categoryId, isMobile])
useEffect(load, [page, pageSize, query, status, categoryId])
function showMore() {
setLoadingMore(true)
setPage((p) => p + 1)
}
useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
@@ -398,6 +423,12 @@ export default function ItemsPage() {
</Link>
</div>
))}
{pagination && pagination.page < pagination.totalPages && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={showMore} disabled={loadingMore}>
{loadingMore ? "Loading…" : "Show more"}
</Button>
)}
</div>
<div className="hidden overflow-x-auto rounded-xl border md:block">
@@ -516,12 +547,14 @@ export default function ItemsPage() {
</div>
{pagination && (
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)}
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Percent, Plus, Search } from "lucide-react"
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, Pencil, Percent, Plus, Search } from "lucide-react"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { errorMessage } from "@/lib/error-map"
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -345,39 +346,7 @@ export default function TaxRatesPage() {
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</>
)}
</div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map"
@@ -382,39 +382,11 @@ export default function RolesPage() {
)}
{pagination && pagination.totalPages > 1 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> roles
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
/>
</div>
)}
</div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
@@ -234,6 +234,14 @@ export default function UsersPage() {
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
<span className="text-muted-foreground">{u.roleName ?? "—"}</span>
<div className="flex items-center gap-1" data-no-row-nav>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -316,6 +324,14 @@ export default function UsersPage() {
</TableCell>
<TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -352,54 +368,13 @@ export default function UsersPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> users
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import { ArrowLeftRight, Boxes, ChevronLeft, ChevronRight, MapPinned, Plus } from "lucide-react"
import { ArrowLeftRight, Boxes, MapPinned, Plus } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
@@ -23,6 +23,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -36,14 +37,13 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Reusable pager for a client-side-sliced table — data for these tables is fetched in
* full up front, so paging is done in-memory rather than via query params. */
function TablePager({
page, pageSize, total, onPageChange, onPageSizeChange, itemLabel,
page, pageSize, total, onPageChange, onPageSizeChange,
}: {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
itemLabel: string
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (total === 0) return null
@@ -65,40 +65,13 @@ function TablePager({
)}
{/* Desktop: full pager. */}
<div className="mt-4 hidden flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between md:flex">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(page * pageSize, total)}</span>{" "}
of <span className="font-medium text-foreground">{total}</span> {itemLabel}
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))}>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button type="button" variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onPageChange(Math.min(totalPages, page + 1))}>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="mt-4 hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems: total, totalPages }}
onPageChange={onPageChange}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={onPageSizeChange}
/>
</div>
</>
)
@@ -322,7 +295,6 @@ export default function WarehouseDetailPage() {
total={bins.length}
onPageChange={setBinsPage}
onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }}
itemLabel="bins"
/>
</div>
)}
@@ -444,7 +416,6 @@ export default function WarehouseDetailPage() {
total={stockRows.length}
onPageChange={setStockPage}
onPageSizeChange={(size) => { setStockPageSize(size); setStockPage(1) }}
itemLabel="items"
/>
</>
)}
@@ -543,7 +514,6 @@ export default function WarehouseDetailPage() {
total={movementByItem.length}
onPageChange={setMovementPage}
onPageSizeChange={(size) => { setMovementPageSize(size); setMovementPage(1) }}
itemLabel="items"
/>
</>
)}
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
import { Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -29,6 +29,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -509,54 +510,13 @@ export default function WarehousesPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between md:flex">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> warehouses
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
@@ -1,12 +1,15 @@
"use client"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState, useEffect } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import OtpInput from "@/components/ui/otp-input"
import { Mail } from "lucide-react"
export default function ForgotOtpPage() {
const router = useRouter()
const [otp, setOtp] = useState("")
const [secondsLeft, setSecondsLeft] = useState(60)
@@ -24,47 +27,54 @@ export default function ForgotOtpPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
<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 mb-8">
<div className="bg-foreground/10 rounded-full p-4 mb-4">
<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>
<AuthShell>
<div className="flex flex-col items-center text-center">
<div className="mb-4 rounded-full bg-primary/10 p-4">
<Mail className="size-6 text-primary" />
</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>
<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"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { validateEmail } from "@/lib/validations"
@@ -18,52 +20,52 @@ export default function ForgotEmailPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 px-4 py-8">
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-border">
<div className="mb-8 text-center">
<h1 className="text-2xl 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>
</div>
<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>
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Reset your password</h1>
<p className="mt-3 text-base text-muted-foreground">
Enter the email address associated with your account and we&apos;ll send you a one-time code to verify your identity.
</p>
</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"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -13,6 +15,17 @@ import {
} from "@/components/ui/dialog"
import { Check, Eye, EyeOff } from "lucide-react"
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<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() {
const router = useRouter()
const [password, setPassword] = useState("")
@@ -28,115 +41,109 @@ export default function ForgotResetPage() {
const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
const PasswordRequirement = ({ met, label }: { met: boolean; label: string }) => (
<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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-border">
<div className="mb-8 text-center">
<h1 className="text-2xl 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>
</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>
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Create a new password</h1>
<p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
</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"
import { Suspense, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
@@ -12,6 +11,7 @@ import { authApi } from "@/lib/api/auth"
import { setStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -55,35 +55,7 @@ function LoginForm() {
})
return (
<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>
{/* 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>
<AuthShell>
<div className="text-center">
<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>
@@ -174,13 +146,6 @@ function LoginForm() {
<div id="form-errors" className="sr-only" aria-live="polite">
{Object.values(form.formState.errors).length > 0 && "There are errors in the form."}
</div>
<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>
</AuthShell>
)
}