Merge pull request 'Dev' (#88) from Dev into production

Reviewed-on: #88
This commit was merged in pull request #88.
This commit is contained in:
2026-09-24 07:12:59 +00:00
104 changed files with 10369 additions and 4763 deletions
@@ -18,12 +18,15 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart" import BarChart from "@/components/ui/bar-chart"
const INVOICE_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Small tinted stat chip for the profile header — value + label + icon. */ /** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: string }) { function StatChip({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: string }) {
return ( return (
@@ -127,18 +130,40 @@ export default function CustomerDetailPage() {
const totalSold = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.grandTotal, 0), [invoices]) const totalSold = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.grandTotal, 0), [invoices])
const outstandingBalance = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.balanceAmount, 0), [invoices]) const outstandingBalance = useMemo(() => (invoices ?? []).reduce((sum, i) => sum + i.totals.balanceAmount, 0), [invoices])
const recentInvoices = useMemo( const recentInvoices = useMemo(
() => (invoices ?? []).slice().sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()).slice(0, 8), () => (invoices ?? []).slice().sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()),
[invoices] [invoices]
) )
const [invoicePage, setInvoicePage] = useState(1)
const [invoicePageSize, setInvoicePageSize] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
const [mobileInvoiceCount, setMobileInvoiceCount] = useState(INVOICE_PAGE_SIZE_OPTIONS[0])
const invoicePagination = {
page: invoicePage,
pageSize: invoicePageSize,
totalItems: recentInvoices.length,
totalPages: Math.max(1, Math.ceil(recentInvoices.length / invoicePageSize)),
}
const pagedInvoices = recentInvoices.slice((invoicePage - 1) * invoicePageSize, invoicePage * invoicePageSize)
const monthlySales = useMemo(() => { const monthlySales = useMemo(() => {
const byMonth = new Map<string, number>() const byMonth = new Map<string, number>()
for (const inv of invoices ?? []) { for (const inv of invoices ?? []) {
const key = inv.invoiceDate.slice(0, 7) const key = inv.invoiceDate.slice(0, 7)
byMonth.set(key, (byMonth.get(key) ?? 0) + inv.totals.grandTotal) byMonth.set(key, (byMonth.get(key) ?? 0) + inv.totals.grandTotal)
} }
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6) return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
}, [invoices]) }, [invoices])
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mql = window.matchMedia("(max-width: 639px)")
setIsMobile(mql.matches)
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
async function handleSave() { async function handleSave() {
if (!customer || !etag) return if (!customer || !etag) return
setSaveError(null) setSaveError(null)
@@ -260,14 +285,15 @@ export default function CustomerDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button size="lg" variant="outline" onClick={openEdit}> <Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={openEdit}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</Button> </Button>
<Button <Button
size="lg" size="lg"
variant={customer.status === "Active" ? "destructive" : "success"} variant={customer.status === "Active" ? "destructive" : "success"}
className="w-full sm:w-auto"
onClick={handleToggleStatus} onClick={handleToggleStatus}
disabled={togglingStatus} disabled={togglingStatus}
> >
@@ -276,7 +302,7 @@ export default function CustomerDetailPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total sold" value={invoices === null ? "…" : `LKR ${totalSold.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} /> <StatChip icon={BadgeDollarSign} label="Total sold" value={invoices === null ? "…" : `LKR ${totalSold.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={Receipt} label="Invoices" value={invoices === null ? "…" : String(invoices.length)} /> <StatChip icon={Receipt} label="Invoices" value={invoices === null ? "…" : String(invoices.length)} />
<StatChip <StatChip
@@ -306,11 +332,31 @@ export default function CustomerDetailPage() {
<div className="flex flex-col gap-3 rounded-xl border p-5"> <div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Monthly sales</h2> <h2 className="text-base font-semibold text-foreground">Monthly sales</h2>
{invoices === null ? ( {invoices === null ? (
<Skeleton className="h-56 w-full" /> <Skeleton className="h-85 w-full" />
) : monthlySales.length > 0 ? ( ) : monthlySales.length > 0 ? (
<BarChart <BarChart
labels={monthlySales.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" }))} // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
datasets={[{ label: "Sold (LKR)", data: monthlySales.map(([, v]) => v), backgroundColor: "rgba(168,85,247,0.6)" }]} // activity, Chart.js would otherwise center the lone category across the full
// width. Trailing blank slots keep the axis/gridlines spanning the full card
// while the real bars sit at the start. Skipped on mobile — there the bars
// should stretch to fill the narrower card instead.
labels={[
...monthlySales.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill("")),
]}
datasets={[
{
label: "Sold (LKR)",
data: [
...monthlySales.map(([, v]) => v),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlySales.length)).fill(null)),
],
backgroundColor: "rgba(239,68,68,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
height={340}
/> />
) : ( ) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No sales recorded yet.</p> <p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No sales recorded yet.</p>
@@ -320,34 +366,105 @@ export default function CustomerDetailPage() {
<div className="flex flex-col gap-3 rounded-xl border p-5"> <div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent sales invoices</h2> <h2 className="text-base font-semibold text-foreground">Recent sales invoices</h2>
{invoices === null ? ( {invoices === null ? (
<Skeleton className="h-56 w-full" /> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
) : recentInvoices.length === 0 ? ( ) : recentInvoices.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sales to this customer yet.</p> <p className="py-6 text-center text-sm text-muted-foreground">No sales to this customer yet.</p>
) : ( ) : (
<Table className="text-sm"> <>
{/* Mobile: stacked cards, "Show more" instead of pagination. */}
<div className="flex flex-col gap-2 md:hidden">
{recentInvoices.slice(0, mobileInvoiceCount).map((inv) => (
<Link
key={inv.salesInvoiceId}
href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`}
className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm hover:bg-muted/20"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-foreground">{inv.invoiceNo}</span>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-muted px-2 text-xs text-muted-foreground">
{inv.status}
</Badge>
<span className="font-medium text-foreground">LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</Link>
))}
{mobileInvoiceCount < recentInvoices.length && (
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => setMobileInvoiceCount((c) => c + INVOICE_PAGE_SIZE_OPTIONS[0])}
>
Show more
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-9 px-2 text-xs">Date</TableHead> <TableHead>Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Invoice</TableHead> <TableHead>Invoice</TableHead>
<TableHead className="h-9 px-2 text-xs">Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="h-9 px-2 text-xs">Total</TableHead> <TableHead className="text-right">Total</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{recentInvoices.map((inv) => ( {pagedInvoices.map((inv) => (
<TableRow key={inv.salesInvoiceId}> <TableRow key={inv.salesInvoiceId} className="align-top hover:bg-muted/20">
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(inv.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2"> <TableCell>
<Link href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`} className="font-medium text-primary hover:underline"> <Link href={`/dashboard/sales/invoices/${inv.salesInvoiceId}`} className="font-semibold text-foreground hover:underline">
{inv.invoiceNo} {inv.invoiceNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-2 py-2">{inv.status}</TableCell> <TableCell>
<TableCell className="px-2 py-2">LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell> <Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-muted px-2.5 text-sm text-muted-foreground">
{inv.status}
</Badge>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">
LKR {inv.totals.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={invoicePagination}
onPageChange={setInvoicePage}
pageSizeOptions={INVOICE_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setInvoicePage(1); setInvoicePageSize(size) }}
/>
</div>
</>
)} )}
</div> </div>
</div> </div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, Trash2, Users } from "lucide-react" import { Eye, Pencil, Plus, Search, Trash2, Users } from "lucide-react"
import { customersApi } from "@/lib/api/customers" import { customersApi } from "@/lib/api/customers"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -37,7 +37,10 @@ import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
type TypeFilter = CustomerType | "All" type TypeFilter = CustomerType | "All"
const PAGE_SIZE = 25 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function CustomersPage() { export default function CustomersPage() {
const router = useRouter() const router = useRouter()
@@ -53,14 +56,15 @@ export default function CustomersPage() {
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [customerType, setCustomerType] = useState<TypeFilter>("All") const [customerType, setCustomerType] = useState<TypeFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [name, setName] = useState("") const [name, setName] = useState("")
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C") const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
const [phone, setPhone] = useState("") const [phone, setPhone] = useState("")
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [creditLimit, setCreditLimit] = useState("0") const [creditLimit, setCreditLimit] = useState("")
const [creditDays, setCreditDays] = useState("0") const [creditDays, setCreditDays] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@@ -68,6 +72,7 @@ export default function CustomersPage() {
// customer's own code (mirrors the vendor create dialog). // customer's own code (mirrors the vendor create dialog).
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([]) const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : "" const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
@@ -86,16 +91,22 @@ export default function CustomersPage() {
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
customerType: customerType === "All" ? undefined : customerType, customerType: customerType === "All" ? undefined : customerType,
page, page,
pageSize: PAGE_SIZE, pageSize,
}) })
.then((res) => { .then((res) => {
setCustomers(res.items) setCustomers(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [query, status, customerType, page]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [query, status, customerType, page, pageSize])
useEffect(() => { useEffect(() => {
customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {}) customersApi.list({ pageSize: 200 }).then((res) => setAllCustomerCodes(res.items.map((c) => c.customerCode))).catch(() => {})
@@ -118,8 +129,8 @@ export default function CustomersPage() {
setNewCustomerType("B2C") setNewCustomerType("B2C")
setPhone("") setPhone("")
setEmail("") setEmail("")
setCreditLimit("0") setCreditLimit("")
setCreditDays("0") setCreditDays("")
setBranchFilterId(null) setBranchFilterId(null)
setErrors({}) setErrors({})
} }
@@ -229,11 +240,11 @@ export default function CustomersPage() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field> <Field>
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel> <FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="c-credit-limit" type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} /> <Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel> <FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
<Input id="c-credit-days" type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} /> <Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field> </Field>
</div> </div>
{showBranchPicker && ( {showBranchPicker && (
@@ -303,9 +314,21 @@ export default function CustomersPage() {
)} )}
{!error && customers === null && ( {!error && customers === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -321,36 +344,133 @@ export default function CustomersPage() {
{!error && customers !== null && customers.length > 0 && ( {!error && customers !== null && customers.length > 0 && (
<> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{customers.map((c) => (
<div
key={c.customerId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${c.customerId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{c.customerCode}</p>
<p className="text-sm text-muted-foreground">{c.displayName ?? c.name}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center text-xs">{c.customerType}</Badge>
</div>
<div>
<p className="text-muted-foreground">Phone</p>
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Credit limit</p>
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${c.customerCode}`}
title="Edit"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.customerCode}`}
disabled={c.status === "Inactive" || actionPendingId === c.customerId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.customerCode}?`}
description={`This deactivates ${c.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its profile.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(c)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && customers.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="w-32">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="w-48">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="w-28">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Phone</TableHead> <TableHead className="w-36">Phone</TableHead>
<TableHead className="h-12 px-3 text-sm">Credit limit</TableHead> <TableHead className="w-36">Credit limit</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="w-28">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="w-24 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{customers.map((c) => ( {customers.map((c) => (
<TableRow <TableRow
key={c.customerId} key={c.customerId}
className="cursor-pointer hover:bg-muted/40" className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/customers/${c.customerId}`) router.push(`/dashboard/customers/${c.customerId}`)
}} }}
> >
<TableCell className="px-3 py-3.5 font-medium">{c.customerCode}</TableCell> <TableCell>
<TableCell className="px-3 py-3.5">{c.displayName ?? c.name}</TableCell> <Link href={`/dashboard/customers/${c.customerId}`} className="font-semibold text-foreground hover:underline">
<TableCell className="px-3 py-3.5"> {c.customerCode}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.displayName ?? c.name}</TableCell>
<TableCell>
<Badge variant="outline">{c.customerType}</Badge> <Badge variant="outline">{c.customerType}</Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.phone ?? "—"}</TableCell> <TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell> <TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@@ -361,12 +481,21 @@ export default function CustomersPage() {
{c.status} {c.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav> <TableCell data-no-row-nav>
<div className="flex items-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${c.customerCode}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/customers/${c.customerId}`} href={`/dashboard/customers/${c.customerId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${c.customerCode}`} aria-label={`Edit ${c.customerCode}`}
title="Edit"
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
@@ -399,8 +528,19 @@ export default function CustomersPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
+57 -49
View File
@@ -4,8 +4,6 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import {
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Clock, Clock,
HelpCircle, HelpCircle,
Inbox, Inbox,
@@ -33,12 +31,16 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const statusStyles: Record<string, string> = { const statusStyles: Record<string, string> = {
Open: "border-transparent bg-primary/10 text-primary", Open: "border-transparent bg-primary/10 text-primary",
@@ -288,7 +290,51 @@ export default function HelpPage() {
)} )}
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && ( {!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pageItems.map((t) => (
<Link
key={t.ticketId}
href={`/dashboard/help/tickets/${t.ticketId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate font-semibold text-foreground">{t.subject}</p>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center bg-white px-2.5 text-sm", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{isAdmin && (
<div>
<p className="text-muted-foreground">Submitted By</p>
<p className="font-medium text-foreground">{t.createdByName}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Messages</p>
<p className="font-medium text-foreground">{t.messageCount}</p>
</div>
<div>
<p className="text-muted-foreground">Last Updated</p>
<p className="font-medium text-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
</Link>
))}
{totalItems > pageItems.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
Show more
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -331,55 +377,17 @@ export default function HelpPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{totalItems > 0 && ( {totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page, pageSize, totalItems, totalPages }}
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{totalItems}</span> tickets onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</p> />
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -123,7 +123,7 @@ export default function SupportTicketDetailPage() {
{isAdmin ? ( {isAdmin ? (
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}> <Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger> <SelectTrigger className="w-full sm:w-44"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Open">Open</SelectItem> <SelectItem value="Open">Open</SelectItem>
<SelectItem value="InProgress">In Progress</SelectItem> <SelectItem value="InProgress">In Progress</SelectItem>
@@ -141,9 +141,9 @@ export default function SupportTicketDetailPage() {
{ticket.messages.map((m) => ( {ticket.messages.map((m) => (
<div <div
key={m.messageId} key={m.messageId}
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-8 bg-primary/5" : "mr-8 bg-card"}`} className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-4 bg-primary/5 sm:ml-8" : "mr-4 bg-card sm:mr-8"}`}
> >
<div className="mb-1 flex items-center justify-between gap-2"> <div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>} {m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
</span> </span>
@@ -162,7 +162,7 @@ export default function SupportTicketDetailPage() {
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"} placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
rows={3} rows={3}
/> />
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}> <Button className="w-full sm:w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
{sending ? "Sending…" : "Send"} {sending ? "Sending…" : "Send"}
</Button> </Button>
</div> </div>
@@ -117,7 +117,7 @@ export default function AttendanceBatchDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1> <h1 className="text-2xl font-bold text-foreground">{batch.docNo}</h1>
<p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p> <p className="text-base text-muted-foreground">{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge> <Badge variant="outline" className="h-7 px-3 text-sm">{batch.status}</Badge>
{batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>} {batch.status === "Draft" && <Button onClick={validate} disabled={busy || unresolved.length > 0}>Validate</Button>}
{batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>} {batch.status === "Validated" && <Button onClick={confirm} disabled={busy}>Confirm</Button>}
@@ -148,6 +148,52 @@ export default function AttendanceBatchDetailPage() {
</div> </div>
)} )}
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{records.map((r) => (
<div key={r.attendanceRecordId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.employeeName ?? r.employeeCode ?? "Unknown"}</p>
<p className="text-sm text-muted-foreground">{new Date(r.attendanceDate).toLocaleDateString()}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", ROW_STATUS_STYLE[r.rowValidationStatus])}>{r.rowValidationStatus}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">In / Out</p>
<p className="font-medium text-foreground">{r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Working Hours</p>
<p className="font-medium text-foreground">{minutesLabel(r.workingMinutes)}</p>
</div>
<div>
<p className="text-muted-foreground">Late</p>
<p className="font-medium text-foreground">{r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"}</p>
</div>
<div>
<p className="text-muted-foreground">OT</p>
<p className="font-medium text-foreground">{r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Row status</p>
<p className="font-medium text-foreground">{r.attendanceStatus}</p>
</div>
</div>
{!locked && r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && (
<div className="flex gap-2 border-t pt-3">
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "keep")}>Keep</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => resolve(r.attendanceRecordId, "discard")}>Discard</Button>
</div>
)}
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -190,5 +236,7 @@ export default function AttendanceBatchDetailPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
</div>
) )
} }
@@ -21,6 +21,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 25
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = { const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
Draft: "bg-muted text-muted-foreground", Draft: "bg-muted text-muted-foreground",
@@ -35,6 +38,8 @@ export default function AttendanceBatchesPage() {
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [periodStart, setPeriodStart] = useState("") const [periodStart, setPeriodStart] = useState("")
@@ -44,11 +49,17 @@ export default function AttendanceBatchesPage() {
function load() { function load() {
attendanceApi attendanceApi
.list({ page, pageSize: PAGE_SIZE }) .list({ page, pageSize })
.then((res) => { setBatches(res.items); setPagination(res.pagination) }) .then((res) => { setBatches(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize])
async function handleUpload() { async function handleUpload() {
if (!file) { toast.error("Choose an Excel or CSV file first"); return } if (!file) { toast.error("Choose an Excel or CSV file first"); return }
@@ -76,12 +87,12 @@ export default function AttendanceBatchesPage() {
<h1 className="text-2xl font-bold text-foreground">Attendance</h1> <h1 className="text-2xl font-bold text-foreground">Attendance</h1>
<p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p> <p className="text-base text-muted-foreground">Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll&apos;s source of truth.</p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-col gap-2 sm:flex-row">
<a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }))}> <a href={attendanceTemplateUrl()} className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
<Download className="size-4" />Download template <Download className="size-4" />Download template
</a> </a>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />Upload</Button>} /> <DialogTrigger render={<Button size="lg" className="w-full sm:w-auto"><Plus className="size-5" />Upload</Button>} />
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Upload Attendance</DialogTitle> <DialogTitle>Upload Attendance</DialogTitle>
@@ -112,6 +123,50 @@ export default function AttendanceBatchesPage() {
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"><p className="text-base text-muted-foreground">No attendance batches yet.</p></div>
)} )}
{!error && batches !== null && batches.length > 0 && ( {!error && batches !== null && batches.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{batches.map((b) => (
<div
key={b.attendanceUploadBatchId}
role="link"
tabIndex={0}
onClick={() => router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/attendance/${b.attendanceUploadBatchId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{b.docNo}</p>
<p className="text-sm text-muted-foreground">
{new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()}
</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[b.status])}>{b.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Rows</p>
<p className="font-medium text-foreground">{b.rowCountTotal}</p>
</div>
<div>
<p className="text-muted-foreground">Duplicates/Errors</p>
<p className="font-medium text-foreground">{b.rowCountDuplicate} / {b.rowCountError}</p>
</div>
</div>
</div>
))}
{pagination && batches.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -138,8 +193,15 @@ export default function AttendanceBatchesPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
</>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)} )}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</div> </div>
) )
} }
@@ -335,7 +335,7 @@ function EmployeeDetailPageContent() {
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={cn("flex-1 shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")} className={cn("shrink-0 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors sm:flex-1", tab === t ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground")}
> >
{t} {t}
</button> </button>
@@ -436,12 +436,12 @@ function EmployeeDetailPageContent() {
</section> </section>
{isEditing && ( {isEditing && (
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCancelEdit} disabled={saving}> <Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={saving}>
<X className="size-4" /> <X className="size-4" />
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button> <Button className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save changes"}</Button>
</div> </div>
)} )}
</div> </div>
@@ -460,12 +460,12 @@ function EmployeeDetailPageContent() {
</section> </section>
{isEditing && ( {isEditing && (
<div className="flex justify-end gap-2"> <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCancelEdit} disabled={savingBank}> <Button variant="outline" className="w-full sm:w-auto" onClick={handleCancelEdit} disabled={savingBank}>
<X className="size-4" /> <X className="size-4" />
Cancel Cancel
</Button> </Button>
<Button onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button> <Button className="w-full sm:w-auto" onClick={handleSaveBank} disabled={savingBank}>{savingBank ? "Saving…" : "Save changes"}</Button>
</div> </div>
)} )}
</div> </div>
@@ -493,7 +493,7 @@ function EmployeeDetailPageContent() {
</Field> </Field>
<label <label
className={cn( className={cn(
"inline-flex h-9 w-fit items-center gap-2 rounded-lg border border-black px-4 text-sm font-medium", "inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-black px-4 text-sm font-medium sm:w-fit",
uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60" uploadTypeId ? "cursor-pointer hover:bg-muted" : "cursor-not-allowed border-input text-muted-foreground opacity-60"
)} )}
title={uploadTypeId ? undefined : "Select a document type first"} title={uploadTypeId ? undefined : "Select a document type first"}
@@ -510,7 +510,48 @@ function EmployeeDetailPageContent() {
</label> </label>
</div> </div>
</section> </section>
<div className="overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10"> {documents.length === 0 ? (
<div className="rounded-2xl bg-card p-10 text-center text-muted-foreground shadow-sm ring-1 ring-foreground/10">No documents uploaded yet.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{documents.map((d) => (
<div key={d.employeeDocumentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{d.originalFileName}</p>
<p className="text-sm text-muted-foreground">{d.hrDocumentTypeName ?? "—"}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{d.status}
</Badge>
</div>
<div className="flex items-center justify-between border-t pt-3 text-sm">
<span className="text-muted-foreground">Uploaded {new Date(d.uploadedAt).toLocaleDateString()}</span>
<a
href={employeesApi.documentDownloadUrl(employeeId, d.employeeDocumentId)}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
target="_blank"
rel="noreferrer"
aria-label={`Download ${d.originalFileName}`}
title={`Download ${d.originalFileName}`}
>
<Download className="size-4" />
</a>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -555,20 +596,19 @@ function EmployeeDetailPageContent() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{documents.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No documents uploaded yet.</TableCell></TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div> </div>
</>
)}
</div> </div>
)} )}
{tab === "Salary & Loans" && ( {tab === "Salary & Loans" && (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10"> <section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Salary Structure</h2> <h2 className="text-lg font-semibold text-foreground">Salary Structure</h2>
<Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}> <Dialog open={structureOpen} onOpenChange={(v) => { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} /> <DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Structure</Button>} />
@@ -652,7 +692,34 @@ function EmployeeDetailPageContent() {
</div> </div>
)} )}
<div className="overflow-hidden rounded-xl border"> {salaryStructures.length === 0 ? (
<div className="rounded-xl border p-10 text-center text-muted-foreground">No salary structure set yet.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{salaryStructures.map((s) => (
<div key={s.employeeSalaryStructureId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</span>
<span className="text-muted-foreground">{s.status}</span>
</div>
<div className="grid grid-cols-2 gap-2 border-t pt-2">
<div>
<p className="text-muted-foreground">Effective to</p>
<p className="font-medium text-foreground">{s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"}</p>
</div>
<div>
<p className="text-muted-foreground">Basic</p>
<p className="font-medium text-foreground">{money(s.basicSalary)}</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -672,17 +739,16 @@ function EmployeeDetailPageContent() {
<TableCell className="px-4 py-3.5">{s.status}</TableCell> <TableCell className="px-4 py-3.5">{s.status}</TableCell>
</TableRow> </TableRow>
))} ))}
{salaryStructures.length === 0 && (
<TableRow><TableCell colSpan={4} className="px-4 py-10 text-center text-muted-foreground">No salary structure set yet.</TableCell></TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div> </div>
</>
)}
</section> </section>
<section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10"> <section className="flex flex-col gap-4 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">Loans &amp; Advances</h2> <h2 className="text-lg font-semibold text-foreground">Loans &amp; Advances</h2>
<Dialog open={loanOpen} onOpenChange={setLoanOpen}> <Dialog open={loanOpen} onOpenChange={setLoanOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} /> <DialogTrigger render={<Button size="sm"><Plus className="size-4" />New Loan</Button>} />
@@ -718,7 +784,34 @@ function EmployeeDetailPageContent() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
<div className="overflow-hidden rounded-xl border"> {loans.length === 0 ? (
<div className="rounded-xl border p-10 text-center text-muted-foreground">No loans or advances on file.</div>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{loans.map((l) => (
<div key={l.employeeLoanId} className="flex flex-col gap-2 rounded-xl border p-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">{l.docNo}</span>
<span className="text-muted-foreground">{l.loanKind} · {l.status}</span>
</div>
<div className="grid grid-cols-2 gap-2 border-t pt-2">
<div>
<p className="text-muted-foreground">Principal</p>
<p className="font-medium text-foreground">{money(l.principalAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Outstanding</p>
<p className="font-medium text-foreground">{money(l.outstandingBalance)}</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -740,13 +833,12 @@ function EmployeeDetailPageContent() {
<TableCell className="px-4 py-3.5">{l.status}</TableCell> <TableCell className="px-4 py-3.5">{l.status}</TableCell>
</TableRow> </TableRow>
))} ))}
{loans.length === 0 && (
<TableRow><TableCell colSpan={5} className="px-4 py-10 text-center text-muted-foreground">No loans or advances on file.</TableCell></TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div> </div>
</>
)}
</section> </section>
</div> </div>
)} )}
@@ -339,11 +339,11 @@ export default function NewEmployeePage() {
</section> </section>
)} )}
<div className="sticky bottom-0 -mx-4 flex justify-end gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:px-6"> <div className="sticky bottom-0 -mx-4 flex flex-col-reverse gap-3 border-t bg-background/95 px-4 py-4 backdrop-blur supports-backdrop-filter:bg-background/80 sm:-mx-6 sm:flex-row sm:justify-end sm:px-6">
<Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }))}> <Link href="/dashboard/hrm/employees" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button onClick={handleCreate} disabled={submitting} className="min-w-40"> <Button onClick={handleCreate} disabled={submitting} className="w-full sm:w-auto sm:min-w-40">
<Save className="size-4" /> <Save className="size-4" />
{submitting ? "Creating…" : "Create employee"} {submitting ? "Creating…" : "Create employee"}
</Button> </Button>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react" import { Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters" import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
@@ -29,6 +29,9 @@ import { toast } from "@/components/ui/toast"
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"] const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function EmployeesPage() { export default function EmployeesPage() {
const router = useRouter() const router = useRouter()
@@ -43,6 +46,7 @@ export default function EmployeesPage() {
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null) const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
@@ -66,6 +70,12 @@ export default function EmployeesPage() {
}) })
.then((res) => { setEmployees(res.items); setPagination(res.pagination) }) .then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId]) useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
useEffect(() => setPage(1), [statusFilter, departmentId, designationId]) useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
@@ -264,7 +274,121 @@ export default function EmployeesPage() {
)} )}
{!error && employees !== null && employees.length > 0 && ( {!error && employees !== null && employees.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{employees.map((e) => (
<div
key={e.employeeId}
role="link"
tabIndex={0}
onClick={(ev) => {
if ((ev.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
onKeyDown={(ev) => {
if (ev.key === "Enter") router.push(`/dashboard/hrm/employees/${e.employeeId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{e.fullName}</p>
<span className="font-mono text-xs text-muted-foreground">{e.employeeCode}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
e.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{e.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Department</p>
<p className="font-medium text-foreground">{e.departmentName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Designation</p>
<p className="font-medium text-foreground">{e.designationName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Hire Date</p>
<p className="font-medium text-foreground">
{new Date(e.hireDate).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</p>
</div>
<div>
<p className="text-muted-foreground">Login</p>
{e.hasUserLink ? (
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center gap-1 border-transparent bg-primary/10 px-2 text-xs text-primary"><Link2 className="size-3.5" />Linked</Badge>
) : (
<p className="font-medium text-muted-foreground">No login</p>
)}
</div>
</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" }))}
aria-label={`Edit ${e.fullName}`}
title={`Edit ${e.fullName}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${e.fullName}`}
title={`Delete ${e.fullName}`}
disabled={actionPendingId === e.employeeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && employees.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -276,7 +400,7 @@ export default function EmployeesPage() {
<TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead> <TableHead className="h-11 w-32 px-3 text-sm">Hire Date</TableHead>
<TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead> <TableHead className="h-11 w-32 px-3 text-sm">Login</TableHead>
<TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead> <TableHead className="h-11 w-28 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-center text-sm">Actions</TableHead> <TableHead className="h-11 w-32 px-3 text-center text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -336,6 +460,9 @@ export default function EmployeesPage() {
<TableCell className="px-3 py-4" data-no-row-nav> <TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/hrm/employees/${e.employeeId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${e.fullName}`} title={`View ${e.fullName}`}>
<Eye className="size-4" />
</Link>
<Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}> <Link href={`/dashboard/hrm/employees/${e.employeeId}?edit=1`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`Edit ${e.fullName}`} title={`Edit ${e.fullName}`}>
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
@@ -370,57 +497,17 @@ export default function EmployeesPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> employees
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react" import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters" import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -26,6 +26,9 @@ import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"] const STATUSES: LeaveRequestStatus[] = ["Draft", "Submitted", "Approved", "Rejected", "Cancelled"]
const ALL_STATUS = "__all__" const ALL_STATUS = "__all__"
@@ -48,6 +51,7 @@ export default function LeaveRequestsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch const hasActiveFilters = statusFilter !== ALL_STATUS || !!debouncedSearch
@@ -77,6 +81,12 @@ export default function LeaveRequestsPage() {
}) })
.then((res) => { setRequests(res.items); setPagination(res.pagination) }) .then((res) => { setRequests(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -304,7 +314,78 @@ export default function LeaveRequestsPage() {
)} )}
{!error && requests !== null && requests.length > 0 && ( {!error && requests !== null && requests.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{requests.map((r) => (
<div key={r.leaveRequestId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.employeeName ?? "—"}</p>
<span className="font-mono text-xs text-muted-foreground">{r.docNo}</span>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{r.leaveTypeName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Days</p>
<p className="font-medium text-foreground">{r.daysCount}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Dates</p>
<p className="font-medium text-foreground">
{new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()}
</p>
</div>
</div>
{r.status === "Submitted" && (
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-success hover:bg-success/10"
aria-label={`Approve ${r.docNo}`}
title={`Approve ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => approve(r.leaveRequestId)}
>
<CheckCircle2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Reject ${r.docNo}`}
title={`Reject ${r.docNo}`}
disabled={actionPendingId === r.leaveRequestId}
onClick={() => openReject(r)}
>
<XCircle className="size-4" />
</Button>
</div>
)}
</div>
))}
{pagination && requests.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -371,57 +452,17 @@ export default function LeaveRequestsPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave requests
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -110,12 +110,12 @@ export default function PayrollLineDetailPage() {
</div> </div>
<div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none"> <div className="rounded-2xl border bg-card p-6 shadow-sm sm:p-8 print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-start justify-between gap-4 border-b border-border pb-5"> <div className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1> <h1 className="text-2xl font-bold text-foreground">Salary Breakdown</h1>
<p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p> <p className="mt-1 text-base text-muted-foreground">{line.employeeName} <span className="font-mono">({line.employeeCode})</span></p>
</div> </div>
<div className="text-right"> <div className="sm:text-right">
<p className="text-sm text-muted-foreground">Period</p> <p className="text-sm text-muted-foreground">Period</p>
<p className="font-semibold text-foreground">{period}</p> <p className="font-semibold text-foreground">{period}</p>
<p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p> <p className="mt-1 text-xs text-muted-foreground">{run.docNo}</p>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, FileText, Search, X } from "lucide-react" import { BadgeDollarSign, FileText, Receipt, Search, Users, Wallet, X } from "lucide-react"
import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll" import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -14,16 +14,35 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size
// client-side, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function money(n: number): string { function money(n: number): string {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
} }
/** Small tinted stat chip — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Wallet; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
export default function PayrollRunDetailPage() { export default function PayrollRunDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
@@ -136,11 +155,11 @@ export default function PayrollRunDetailPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Gross</p><p className="text-xl font-bold">{money(run.totalGross)}</p></div> <StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Net</p><p className="text-xl font-bold">{money(run.totalNet)}</p></div> <StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Employees</p><p className="text-xl font-bold">{run.employeeCount}</p></div> <StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
<div className="rounded-xl border bg-card p-4"><p className="text-sm text-muted-foreground">Deductions</p><p className="text-xl font-bold">{money(run.totalGross - run.totalNet)}</p></div> <StatChip icon={Receipt} label="Deductions" value={money(run.totalGross - run.totalNet)} />
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -177,7 +196,78 @@ export default function PayrollRunDetailPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{pagedLines.map((l) => {
const totalDeductions = l.grossSalary - l.netSalary
const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId)
return (
<div
key={l.payrollLineId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`)
}}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${runId}/lines/${l.payrollLineId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{l.employeeName}</p>
<span className="font-mono text-xs text-muted-foreground">{l.employeeCode}</span>
</div>
{payslip && (
<a
href={payslipViewUrl(payslip.payslipId)}
target="_blank"
rel="noreferrer"
data-no-row-nav
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "shrink-0")}
aria-label={`View payslip for ${l.employeeName}`}
title="View payslip"
>
<FileText className="size-4" />
</a>
)}
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Basic</p>
<p className="font-medium text-foreground">{money(l.basicSalary)}</p>
</div>
<div>
<p className="text-muted-foreground">OT</p>
<p className="font-medium text-foreground">{money(l.overtimeAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Allowances</p>
<p className="font-medium text-foreground">{money(l.totalAllowances)}</p>
</div>
<div>
<p className="text-muted-foreground">Deductions</p>
<p className="font-medium text-foreground">{money(totalDeductions)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Net Salary</p>
<p className="font-semibold text-foreground">{money(l.netSalary)}</p>
</div>
</div>
</div>
)
})}
{filteredLines.length > pagedLines.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)}>
Show more
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -235,55 +325,17 @@ export default function PayrollRunDetailPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{filteredLines.length > 0 && ( {filteredLines.length > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={{ page: currentPage, pageSize, totalItems: filteredLines.length, totalPages }}
Showing <span className="font-medium text-foreground">{(currentPage - 1) * pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground">{Math.min(currentPage * pageSize, filteredLines.length)}</span>{" "} pageSizeOptions={PAGE_SIZE_OPTIONS}
of <span className="font-medium text-foreground">{filteredLines.length}</span> lines onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</p> />
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Plus } from "lucide-react" import { Plus } from "lucide-react"
import { payrollRunsApi } from "@/lib/api/payroll" import { payrollRunsApi } from "@/lib/api/payroll"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -16,11 +16,15 @@ import { Badge } from "@/components/ui/badge"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"] const STATUSES: PayrollRunStatus[] = ["Draft", "Approved", "Locked"]
const ALL_STATUS = "__all__" const ALL_STATUS = "__all__"
@@ -38,6 +42,7 @@ export default function PayrollRunsPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5) const [pageSize, setPageSize] = useState(5)
const [statusFilter, setStatusFilter] = useState(ALL_STATUS) const [statusFilter, setStatusFilter] = useState(ALL_STATUS)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const now = new Date() const now = new Date()
@@ -50,6 +55,12 @@ export default function PayrollRunsPage() {
.list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) }) .list({ page, pageSize, status: statusFilter === ALL_STATUS ? undefined : (statusFilter as PayrollRunStatus) })
.then((res) => { setRuns(res.items); setPagination(res.pagination) }) .then((res) => { setRuns(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -142,7 +153,53 @@ export default function PayrollRunsPage() {
)} )}
{!error && runs !== null && runs.length > 0 && ( {!error && runs !== null && runs.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{runs.map((r) => (
<div
key={r.payrollRunId}
role="link"
tabIndex={0}
onClick={() => router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/hrm/payroll/${r.payrollRunId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
<p className="text-sm text-muted-foreground">{r.periodMonth.toString().padStart(2, "0")}/{r.periodYear}</p>
</div>
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm", STATUS_STYLE[r.status])}>
{r.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Employees</p>
<p className="font-medium text-foreground">{r.employeeCount}</p>
</div>
<div>
<p className="text-muted-foreground">Gross</p>
<p className="font-medium text-foreground">{r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
<div>
<p className="text-muted-foreground">Net</p>
<p className="font-medium text-foreground">{r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
</div>
</div>
))}
{pagination && runs.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -178,57 +235,17 @@ export default function PayrollRunsPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> payroll runs
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters" import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const NONE = "__none__" const NONE = "__none__"
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DepartmentsPage() { export default function DepartmentsPage() {
const [items, setItems] = useState<Department[] | null>(null) const [items, setItems] = useState<Department[] | null>(null)
@@ -34,6 +37,7 @@ export default function DepartmentsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -60,6 +64,12 @@ export default function DepartmentsPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -285,9 +295,9 @@ export default function DepartmentsPage() {
</Select> </Select>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -356,7 +366,105 @@ export default function DepartmentsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((d) => (
<div key={d.departmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{d.name}</p>
<span className="font-mono text-xs text-muted-foreground">{d.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
d.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{d.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Parent</p>
<p className="font-medium text-foreground">
{items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"}
</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{new Date(d.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${d.name}`}
title={`Edit ${d.name}`}
onClick={() => openEdit(d)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
d.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
title={d.status === "Active" ? `Deactivate ${d.name}` : `Activate ${d.name}`}
disabled={actionPendingId === d.departmentId}
onClick={() => toggleStatus(d)}
>
{d.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${d.name}`}
title={`Delete ${d.name}`}
disabled={actionPendingId === d.departmentId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${d.name}?`}
description="This permanently removes this department. This cannot be undone. Blocked if it has sub-departments or employees assigned — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(d)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
@@ -450,57 +558,17 @@ export default function DepartmentsPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> departments
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { hrDocumentTypesApi } from "@/lib/api/hrm-masters" import { hrDocumentTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -24,6 +24,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"] const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function DocumentTypesPage() { export default function DocumentTypesPage() {
const [items, setItems] = useState<HrDocumentType[] | null>(null) const [items, setItems] = useState<HrDocumentType[] | null>(null)
@@ -34,6 +37,7 @@ export default function DocumentTypesPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -59,6 +63,12 @@ export default function DocumentTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) }) .then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -261,9 +271,9 @@ export default function DocumentTypesPage() {
<FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel> <FieldLabel htmlFor="dt-edit-expiry">Track expiry date (e.g. passport, visa)</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -332,7 +342,103 @@ export default function DocumentTypesPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((t) => (
<div key={t.hrDocumentTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.name}</p>
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Category</p>
<p className="font-medium text-foreground">{t.category}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.hrDocumentTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this document type. This cannot be undone. Blocked if it's referenced by uploaded documents — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
@@ -424,57 +530,17 @@ export default function DocumentTypesPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> document types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { leaveTypesApi } from "@/lib/api/hrm-masters" import { leaveTypesApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -22,6 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function LeaveTypesPage() { export default function LeaveTypesPage() {
const [items, setItems] = useState<LeaveType[] | null>(null) const [items, setItems] = useState<LeaveType[] | null>(null)
@@ -32,6 +35,7 @@ export default function LeaveTypesPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -60,6 +64,12 @@ export default function LeaveTypesPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) }) .then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -279,9 +289,9 @@ export default function LeaveTypesPage() {
<FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel> <FieldLabel htmlFor="lt-edit-approval">Requires approval</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -351,7 +361,107 @@ export default function LeaveTypesPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((t) => (
<div key={t.leaveTypeId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.name}</p>
<span className="font-mono text-xs text-muted-foreground">{t.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Days/yr</p>
<p className="font-medium text-foreground">{t.accrualPerYear}</p>
</div>
<div>
<p className="text-muted-foreground">Paid</p>
<p className="font-medium text-foreground">{t.isPaid ? "Yes" : "No"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{new Date(t.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
title={`Edit ${t.name}`}
onClick={() => openEdit(t)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
t.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
title={t.status === "Active" ? `Deactivate ${t.name}` : `Activate ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
onClick={() => toggleStatus(t)}
>
{t.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${t.name}`}
title={`Delete ${t.name}`}
disabled={actionPendingId === t.leaveTypeId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${t.name}?`}
description="This permanently removes this leave type. This cannot be undone. Blocked if it's referenced by leave requests or balances — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(t)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
@@ -445,57 +555,17 @@ export default function LeaveTypesPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> leave types
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { salaryComponentsApi } from "@/lib/api/hrm-masters" import { salaryComponentsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function SalaryComponentsPage() { export default function SalaryComponentsPage() {
const [items, setItems] = useState<SalaryComponent[] | null>(null) const [items, setItems] = useState<SalaryComponent[] | null>(null)
@@ -33,6 +36,7 @@ export default function SalaryComponentsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -57,6 +61,12 @@ export default function SalaryComponentsPage() {
.list({ page, pageSize, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { setItems(res.items); setPagination(res.pagination) }) .then((res) => { setItems(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -254,9 +264,9 @@ export default function SalaryComponentsPage() {
<FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel> <FieldLabel htmlFor="sc-edit-epfetf">EPF/ETF applicable</FieldLabel>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -325,7 +335,103 @@ export default function SalaryComponentsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((c) => (
<div key={c.salaryComponentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{c.name}</p>
<span className="font-mono text-xs text-muted-foreground">{c.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.componentType}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{new Date(c.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
title={`Edit ${c.name}`}
onClick={() => openEdit(c)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
c.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
title={c.status === "Active" ? `Deactivate ${c.name}` : `Activate ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
onClick={() => toggleStatus(c)}
>
{c.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${c.name}`}
title={`Delete ${c.name}`}
disabled={actionPendingId === c.salaryComponentId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes this salary component. This cannot be undone. Blocked if it's referenced by salary structures — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
@@ -417,57 +523,17 @@ export default function SalaryComponentsPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> salary components
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -141,7 +141,38 @@ export default function StatutorySettingsPage() {
)} )}
{settings !== null && settings.length > 0 && ( {settings !== null && settings.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{settings.map((s) => (
<div key={s.payrollStatutorySettingId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<p className="font-semibold text-foreground">
{new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"}
</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">EPF (Employee)</p>
<p className="font-medium text-foreground">{(s.epfEmployeeRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">EPF (Employer)</p>
<p className="font-medium text-foreground">{(s.epfEmployerRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">ETF (Employer)</p>
<p className="font-medium text-foreground">{(s.etfEmployerRate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-muted-foreground">OT multiplier</p>
<p className="font-medium text-foreground">{s.otMultiplierDefault}x</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -169,6 +200,7 @@ export default function StatutorySettingsPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
</section> </section>
@@ -225,7 +257,24 @@ export default function StatutorySettingsPage() {
)} )}
{slabs !== null && slabs.length > 0 && ( {slabs !== null && slabs.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{slabs.map((s) => (
<div key={s.taxSlabId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-semibold text-foreground">{new Date(s.effectiveFrom).toLocaleDateString()}</p>
<p className="font-medium text-foreground">{(s.rate * 100).toFixed(1)}%</p>
</div>
<p className="border-t pt-3 text-sm text-muted-foreground">
{s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"}
</p>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -247,6 +296,7 @@ export default function StatutorySettingsPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
</section> </section>
</div> </div>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { workShiftsApi } from "@/lib/api/hrm-masters" import { workShiftsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -23,6 +23,9 @@ import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
function maskToDays(mask: number): boolean[] { function maskToDays(mask: number): boolean[] {
return DAYS.map((_, i) => (mask & (1 << i)) !== 0) return DAYS.map((_, i) => (mask & (1 << i)) !== 0)
@@ -37,6 +40,7 @@ export default function WorkShiftsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -75,6 +79,12 @@ export default function WorkShiftsPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -218,7 +228,7 @@ export default function WorkShiftsPage() {
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} /> <DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Shift</Button>} />
<DialogContent className="sm:max-w-md"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New Work Shift</DialogTitle> <DialogTitle>New Work Shift</DialogTitle>
<DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription> <DialogDescription>Standard hours, grace period, and working days for this shift.</DialogDescription>
@@ -287,7 +297,7 @@ export default function WorkShiftsPage() {
</div> </div>
<Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}> <Dialog open={!!editItem} onOpenChange={(v) => !v && setEditItem(null)}>
<DialogContent className="sm:max-w-md"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Edit Work Shift</DialogTitle> <DialogTitle>Edit Work Shift</DialogTitle>
<DialogDescription>Code can&apos;t be changed after creation.</DialogDescription> <DialogDescription>Code can&apos;t be changed after creation.</DialogDescription>
@@ -356,9 +366,9 @@ export default function WorkShiftsPage() {
</div> </div>
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -427,7 +437,103 @@ export default function WorkShiftsPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((w) => (
<div key={w.workShiftId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{w.name}</p>
<span className="font-mono text-xs text-muted-foreground">{w.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
w.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{w.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Hours</p>
<p className="font-medium text-foreground">{w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{new Date(w.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${w.name}`}
title={`Edit ${w.name}`}
onClick={() => openEdit(w)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
w.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
title={w.status === "Active" ? `Deactivate ${w.name}` : `Activate ${w.name}`}
disabled={actionPendingId === w.workShiftId}
onClick={() => toggleStatus(w)}
>
{w.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${w.name}`}
title={`Delete ${w.name}`}
disabled={actionPendingId === w.workShiftId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.name}?`}
description="This permanently removes this work shift. This cannot be undone. Blocked if it's assigned to any employees — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
@@ -519,57 +625,17 @@ export default function WorkShiftsPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> work shifts
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -112,7 +112,7 @@ export default function BalanceSheetPage() {
)} )}
{!error && report !== null && !isEmpty && ( {!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} /> <ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<div className="mt-4"> <div className="mt-4">
@@ -103,9 +103,10 @@ export default function GeneralLedgerReportPage() {
)} )}
{!error && rows !== null && rows.length > 0 && ( {!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} /> <ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
<Table className="mt-4 text-base"> <div className="mt-4 overflow-x-auto">
<Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead> <TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
@@ -114,7 +115,6 @@ export default function GeneralLedgerReportPage() {
<TableHead className="h-11 px-3 text-sm">Description</TableHead> <TableHead className="h-11 px-3 text-sm">Description</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead> <TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead> <TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -131,12 +131,12 @@ export default function GeneralLedgerReportPage() {
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell> <TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell> <TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
)} )}
</div> </div>
) )
@@ -127,7 +127,7 @@ export default function ProfitAndLossPage() {
)} )}
{!error && report !== null && !isEmpty && ( {!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader <ReportHeader
title="Statement of Profit or Loss" title="Statement of Profit or Loss"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
@@ -230,7 +230,7 @@ export default function TaxReportPage() {
)} )}
{!error && report !== null && ( {!error && report !== null && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller {/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller
identity block (company name/address/TIN/BRN) that today lives only in GL's own identity block (company name/address/TIN/BRN) that today lives only in GL's own
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here — appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
@@ -97,9 +97,10 @@ export default function TrialBalancePage() {
)} )}
{!error && rows !== null && rows.length > 0 && ( {!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-black/5 sm:p-6">
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} /> <ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<Table className="mt-4 text-base"> <div className="mt-4 overflow-x-auto">
<Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead> <TableHead className="h-11 px-3 text-sm">Account</TableHead>
@@ -128,6 +129,7 @@ export default function TrialBalancePage() {
</TableFooter> </TableFooter>
</Table> </Table>
</div> </div>
</div>
)} )}
</div> </div>
) )
+70 -5
View File
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import {
AlertTriangle, AlertTriangle,
ArrowDownLeft,
ArrowUpRight,
BadgeDollarSign, BadgeDollarSign,
Boxes, Boxes,
ClipboardList, ClipboardList,
@@ -168,7 +170,7 @@ export default function DashboardPage() {
{loaded ? ( {loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? ( (stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64"> <div className="h-64 sm:h-72">
<BarChart <BarChart
labels={stats.stockValuationByWarehouse.map( labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` (w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
@@ -186,7 +188,7 @@ export default function DashboardPage() {
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p> <p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
) )
) : ( ) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" /> !error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)} )}
</div> </div>
@@ -199,7 +201,7 @@ export default function DashboardPage() {
</div> </div>
{loaded ? ( {loaded ? (
<div className="h-52 sm:h-64"> <div className="h-64 sm:h-72">
<LineChart <LineChart
labels={movementTrend.labels} labels={movementTrend.labels}
datasets={[ datasets={[
@@ -209,7 +211,7 @@ export default function DashboardPage() {
/> />
</div> </div>
) : ( ) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" /> !error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)} )}
</div> </div>
</div> </div>
@@ -227,7 +229,67 @@ export default function DashboardPage() {
{loaded ? ( {loaded ? (
movements.length > 0 ? ( movements.length > 0 ? (
<Table> <>
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<div className="flex flex-col gap-3 md:hidden">
{movements.map((entry) => (
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
)}
>
{entry.direction === "In" ? (
<ArrowDownLeft className="size-4 text-success" />
) : (
<ArrowUpRight className="size-4 text-destructive" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
<p className="text-sm text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</p>
</div>
</div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{entry.qtyBase}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="min-w-160">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Item</TableHead> <TableHead>Item</TableHead>
@@ -269,6 +331,9 @@ export default function DashboardPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
</>
) : ( ) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p> <p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
) )
@@ -126,7 +126,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
return ( return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}> <Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
@@ -136,7 +136,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
<p className="text-base text-muted-foreground">{invoice.docNo}</p> <p className="text-base text-muted-foreground">{invoice.docNo}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{invoice.status !== "Draft" && ( {invoice.status !== "Draft" && (
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<BookOpen className="size-5" /> <BookOpen className="size-5" />
@@ -157,7 +157,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>} {error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
<section className="rounded-2xl border bg-card p-6 shadow-sm"> <section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="border-b pb-4"> <div className="border-b pb-4">
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}> <div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
<div> <div>
@@ -314,11 +314,11 @@ function NewPurchaseInvoiceContent() {
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}> <Button size="lg" type="button" onClick={submit} disabled={saving} className="w-full sm:w-auto">
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"} {saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
</Button> </Button>
</div> </div>
@@ -3,10 +3,11 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText } from "lucide-react" import { CheckCircle2, CircleDollarSign, Filter, Plus, Printer, ReceiptText, TrendingUp } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { dashboardApi } from "@/lib/api/dashboard"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -16,10 +17,12 @@ import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { MonthlyPurchasesVsSales } from "@/types/dashboard"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices" import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -47,6 +50,15 @@ export default function PurchaseInvoicesPage() {
const [confirmingId, setConfirmingId] = useState<number | null>(null) const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null) const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false) const [payDialogOpen, setPayDialogOpen] = useState(false)
const [monthlyPurchases, setMonthlyPurchases] = useState<MonthlyPurchasesVsSales[] | null>(null)
useEffect(() => {
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.stats({ branchId })
.then((res) => setMonthlyPurchases(res.monthlyPurchasesVsSales))
.catch(() => setMonthlyPurchases([]))
}, [activeBranchId, branchingEnabled])
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -142,6 +154,30 @@ export default function PurchaseInvoicesPage() {
</div> </div>
</div> </div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<TrendingUp className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">Monthly Purchases</h2>
</div>
{monthlyPurchases === null ? (
<Skeleton className="h-64 rounded-lg sm:h-72" />
) : monthlyPurchases.length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={monthlyPurchases.map((m) =>
new Date(`${m.month}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })
)}
datasets={[
{ label: "Purchases", data: monthlyPurchases.map((m) => m.purchases), backgroundColor: "#E11D48" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No posted invoices yet.</p>
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm"> <div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center"> <div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -192,7 +228,99 @@ export default function PurchaseInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && ( {!error && rows !== null && visibleRows.length > 0 && (
<> <>
<div className="overflow-x-auto"> <div className="flex flex-col gap-3 p-4 md:hidden">
{visibleRows.map((row) => (
<div
key={row.purchaseInvoiceId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
data-no-row-nav
className="font-semibold text-foreground hover:underline"
>
{row.docNo}
</Link>
<p className="truncate text-sm text-muted-foreground">{row.vendorSnapshotName}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<PurchaseInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Vendor invoice no.</p>
<p className="font-medium text-foreground">{row.vendorInvoiceNo}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</p>
</div>
<div>
<p className="text-muted-foreground">GRNs</p>
<p className="font-medium text-foreground tabular-nums">{row.grnCount}</p>
</div>
<div>
<p className="text-muted-foreground">Total</p>
<p className="font-semibold text-foreground tabular-nums">{row.totals.grandTotal.toFixed(2)}</p>
</div>
{isAdmin && branchingEnabled && (
<div>
<p className="text-muted-foreground">Branch</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(row.branchId)}</Badge>
</div>
)}
</div>
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.purchaseInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.purchaseInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
)}
</div>
))}
</div>
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -322,16 +322,16 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
{po.status === "Draft" && ( {po.status === "Draft" && (
<> <>
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}> <Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" /> <Send className="size-5" />
{submitting ? "Submitting…" : "Submit for approval"} {submitting ? "Submitting…" : "Submit for approval"}
</Button> </Button>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger <AlertDialogTrigger
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />} render={<Button variant="destructive" size="lg" className="w-full sm:w-auto" disabled={deleting || submitting} />}
> >
<Trash2 className="size-5" /> <Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"} {deleting ? "Deleting…" : "Delete draft"}
@@ -348,11 +348,11 @@ export default function PurchaseOrderDetailPage() {
)} )}
{po.status === "PendingApproval" && isAdmin && ( {po.status === "PendingApproval" && isAdmin && (
<> <>
<Button variant="success" size="lg" onClick={handleApprove} disabled={approving || rejecting}> <Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" /> <Check className="size-5" />
{approving ? "Approving…" : "Approve"} {approving ? "Approving…" : "Approve"}
</Button> </Button>
<Button variant="destructive" size="lg" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}> <Button variant="destructive" size="lg" className="w-full sm:w-auto" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
<X className="size-5" /> <X className="size-5" />
Reject Reject
</Button> </Button>
@@ -362,6 +362,7 @@ export default function PurchaseOrderDetailPage() {
<Button <Button
variant="destructive" variant="destructive"
size="lg" size="lg"
className="w-full sm:w-auto"
onClick={() => setShowCancelForm(true)} onClick={() => setShowCancelForm(true)}
disabled={hasReceipts} disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined} title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
@@ -380,13 +381,13 @@ export default function PurchaseOrderDetailPage() {
value={cancelReason} value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)} onChange={(e) => setCancelReason(e.target.value)}
placeholder="Reason (e.g. Duplicate order)" placeholder="Reason (e.g. Duplicate order)"
className="h-11 max-w-md text-base" className="h-11 w-full text-base sm:max-w-md"
/> />
<div className="flex gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row">
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
Back Back
</Button> </Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}> <Button variant="destructive" className="w-full sm:w-auto" onClick={handleCancel} disabled={cancelling}>
{cancelling ? "Cancelling…" : "Confirm cancellation"} {cancelling ? "Cancelling…" : "Confirm cancellation"}
</Button> </Button>
</div> </div>
@@ -437,7 +438,7 @@ export default function PurchaseOrderDetailPage() {
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
{editable && ( {editable && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}> <Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
@@ -447,7 +448,148 @@ export default function PurchaseOrderDetailPage() {
)} )}
</div> </div>
<div className="overflow-x-auto"> {/* Mobile: stacked cards — read-only summary or editable fields depending on `editable`. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
if (!editable) {
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">UOM</p>
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)}</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Qty / Received</p>
<p className="font-medium text-foreground">{line.qty} / {line.qtyReceived}</p>
</div>
<div>
<p className="text-muted-foreground">Unit price / Tax</p>
<p className="font-medium text-foreground">{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%</p>
</div>
</div>
</div>
)
}
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<FieldLabel className="text-sm">Item</FieldLabel>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
aria-label="Remove line"
disabled={line.qtyReceived > 0}
title={line.qtyReceived > 0 ? "Cannot remove a line that already has receipts" : undefined}
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FieldLabel className="text-sm">UOM</FieldLabel>
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</div>
<div>
<FieldLabel className="text-sm">Warehouse</FieldLabel>
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FieldLabel className="text-sm">Qty (received {line.qtyReceived})</FieldLabel>
<Input
type="number"
min={line.qtyReceived}
step="any"
inputMode="decimal"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
<div>
<FieldLabel className="text-sm">Unit price</FieldLabel>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</div>
</div>
<div>
<FieldLabel className="text-sm">Tax</FieldLabel>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={line.tax}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
className="h-11 w-full text-base sm:w-32"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -574,11 +716,11 @@ export default function PurchaseOrderDetailPage() {
</div> </div>
{editable && ( {editable && (
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}> <Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
Back Back
</Button> </Button>
<Button size="lg" onClick={handleSave} disabled={saving}> <Button size="lg" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
@@ -430,9 +430,9 @@ function NewPurchaseOrderContent() {
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
href="/dashboard/products/new" href="/dashboard/products/new"
target="_blank" target="_blank"
@@ -451,7 +451,117 @@ function NewPurchaseOrderContent() {
</div> </div>
{lines.length > 0 && ( {lines.length > 0 && (
<div className="overflow-x-auto"> <>
{/* Mobile: stacked editable cards, one per line. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item</Label>
{requisitionId || rfqId ? (
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger
className="h-11! w-full text-base"
aria-invalid={!!errors.itemId}
title={item ? `${item.sku} — ${item.name}` : undefined}
>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">UOM</Label>
<div className="flex h-11 items-center text-base text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</div>
<div>
<Label className="text-sm">Warehouse</Label>
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Qty</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
<div>
<Label className="text-sm">Unit price</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</div>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -549,6 +659,7 @@ function NewPurchaseOrderContent() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</>
)} )}
</div> </div>
@@ -556,14 +667,14 @@ function NewPurchaseOrderContent() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}> <Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting}>
{submitting ? "Saving…" : "Save as draft"} {submitting ? "Saving…" : "Save as draft"}
</Button> </Button>
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? "Creating…" : "Create & submit"} {submitting ? "Creating…" : "Create & submit"}
</Button> </Button>
</div> </div>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react" import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
@@ -30,7 +30,10 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All" type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseOrdersListPage() { export default function PurchaseOrdersListPage() {
const router = useRouter() const router = useRouter()
@@ -46,8 +49,10 @@ export default function PurchaseOrdersListPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null) const [submittingId, setSubmittingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -59,15 +64,21 @@ export default function PurchaseOrdersListPage() {
function load() { function load() {
setError(null) setError(null)
purchaseOrdersApi purchaseOrdersApi
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status }) .list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
.then((res) => { .then((res) => {
setPos(res.items) setPos(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page, query, status]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status])
useEffect(() => { useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, []) }, [])
@@ -124,12 +135,15 @@ export default function PurchaseOrdersListPage() {
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1> <h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p> <p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Link href="/dashboard/procurement/purchase-orders/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link
href="/dashboard/procurement/purchase-orders/approvals"
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
>
<Clock className="size-5" /> <Clock className="size-5" />
Approvals queue Approvals queue
</Link> </Link>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New PO New PO
</Link> </Link>
@@ -173,9 +187,21 @@ export default function PurchaseOrdersListPage() {
)} )}
{!error && pos === null && ( {!error && pos === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -189,16 +215,135 @@ export default function PurchaseOrdersListPage() {
{!error && visiblePos !== null && visiblePos.length > 0 && ( {!error && visiblePos !== null && visiblePos.length > 0 && (
<> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visiblePos.map((po) => {
const editable = isPoEditable(po.status)
return (
<div
key={po.poId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
</div>
<PoStatusBadge status={po.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Grand total</p>
<p className="font-medium text-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
</div>
{isAdmin && branchingEnabled && (
<div className="col-span-2">
<p className="text-muted-foreground">Branch</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{branchName(po.branchId)}
</Badge>
</div>
)}
</div>
{editable && (
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Edit draft"
title="Edit draft"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Submit for approval"
title="Submit for approval"
disabled={submittingId === po.poId || deletingId === po.poId}
className="text-success hover:bg-success/10 hover:text-success"
/>
}
>
<Send className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="success"
title={`Submit ${po.docNo} for approval?`}
description="It will be locked for editing while it awaits an Admin's approval."
confirmLabel="Submit"
onConfirm={() => handleSubmit(po)}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || submittingId === po.poId}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete draft ${po.docNo}?`}
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={() => handleDelete(po)}
/>
</AlertDialog>
</div>
)}
</div>
)
})}
{pagination && pos !== null && pos.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="w-48">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead> <TableHead>Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead> <TableHead>Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>} {isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead> <TableHead className="w-44 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -207,30 +352,38 @@ export default function PurchaseOrdersListPage() {
return ( return (
<TableRow <TableRow
key={po.poId} key={po.poId}
className="cursor-pointer hover:bg-muted/40" className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
}} }}
> >
<TableCell className="px-3 py-3.5"> <TableCell>
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> <Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-semibold text-foreground hover:underline">
{po.docNo} {po.docNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell> <TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && ( {isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge> <Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
</TableCell> </TableCell>
)} )}
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav> <TableCell data-no-row-nav>
<div className="flex items-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="View purchase order"
title="View"
>
<Eye className="size-4" />
</Link>
{editable && ( {editable && (
<> <>
<Link <Link
@@ -298,8 +451,19 @@ export default function PurchaseOrdersListPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -210,6 +210,55 @@ function NewPurchaseReturnContent() {
{selectedGrn && ( {selectedGrn && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2> <h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{selectedGrn.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<label className="flex items-start gap-3">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
<span className="min-w-0 truncate text-base font-medium text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</span>
</label>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Received qty</p>
<p className="font-medium text-foreground">{line.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Hold status</p>
<HoldStatusBadge status={line.holdStatus} />
</div>
</div>
<div>
<Label className="text-sm">Return qty</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={state.qty}
disabled={!state.selected}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -254,17 +303,18 @@ function NewPurchaseReturnContent() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
)} )}
{submitError && ( {submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"} {submitting ? "Posting…" : "Post Return"}
</Button> </Button>
</div> </div>
@@ -9,31 +9,70 @@ import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes" import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { PurchaseReturnSummary } from "@/types/procurement" import { PurchaseReturnSummary } from "@/types/procurement"
import { Vendor, Warehouse } from "@/types/master-data" import { Vendor, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function PurchaseReturnsListPage() { export default function PurchaseReturnsListPage() {
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null) const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([]) const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([]) const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
.then(([r, v, w, rc]) => { return () => clearTimeout(timeout)
setReturns(r.items) }, [searchInput])
useEffect(() => setPage(1), [query])
function load() {
setError(null)
purchaseReturnsApi
.list({ page, pageSize, q: query || undefined })
.then((res) => {
setReturns(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query])
useEffect(() => {
Promise.all([vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([v, w, rc]) => {
setVendors(v.items) setVendors(v.items)
setWarehouses(w.items) setWarehouses(w.items)
setReasonCodes(rc.items) setReasonCodes(rc.items)
}) })
.catch((err) => setError(errorMessage(err))) .catch(() => {})
}, []) }, [])
function vendorCode(id: number) { function vendorCode(id: number) {
@@ -46,6 +85,8 @@ export default function PurchaseReturnsListPage() {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
} }
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -53,20 +94,42 @@ export default function PurchaseReturnsListPage() {
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1> <h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p> <p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
</div> </div>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Return New Return
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., vendor…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search purchase returns"
/>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && returns === null && ( {!error && returns === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -74,45 +137,107 @@ export default function PurchaseReturnsListPage() {
{!error && returns !== null && returns.length === 0 && ( {!error && returns !== null && returns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageX className="size-12 text-muted-foreground" /> <PackageX className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No purchase returns yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No purchase returns match your search." : "No purchase returns yet."}</p>
{!hasFilters && (
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" /> <Plus className="size-5" />
New Return New Return
</Link> </Link>
)}
</div> </div>
)} )}
{!error && returns !== null && returns.length > 0 && ( {!error && returns !== null && returns.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{returns.map((r) => (
<div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</p>
</div>
<Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Reason</p>
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
</div>
<div>
<p className="text-muted-foreground">GL Journal</p>
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))}
{pagination && returns !== null && returns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="w-48">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead> <TableHead>Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <TableHead>Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead> <TableHead>Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead> <TableHead>GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead>Created</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{returns.map((r) => ( {returns.map((r) => (
<TableRow key={r.returnId}> <TableRow key={r.returnId} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{vendorCode(r.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success"> <Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell> <TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -9,6 +9,7 @@ import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates" import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PaginationMeta } from "@/types/common"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production" import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
@@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -34,6 +36,10 @@ function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }) return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
} }
// The produced list comes back whole (no server paging), so pages are sliced client-side from
// the already-loaded lines. The summary totals still cover the whole filtered list.
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
export default function ProducedItemsPage() { export default function ProducedItemsPage() {
const router = useRouter() const router = useRouter()
@@ -49,6 +55,8 @@ export default function ProducedItemsPage() {
const [templateId, setTemplateId] = useState<number | null>(null) const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const load = useCallback(() => { const load = useCallback(() => {
setLoadError(null) setLoadError(null)
@@ -60,7 +68,11 @@ export default function ProducedItemsPage() {
warehouseId: warehouseId ?? undefined, warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
}) })
.then(setData) .then((res) => {
setData(res)
// A new filter is a new list — start back at the first page.
setPage(1)
})
.catch((err) => { .catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } }) setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
@@ -80,26 +92,37 @@ export default function ProducedItemsPage() {
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All" const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
const totalItems = data?.items.length ?? 0
const pagination: PaginationMeta = {
page,
pageSize,
totalItems,
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
}
const pageItems = data?.items.slice((page - 1) * pageSize, page * pageSize) ?? []
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Produced Items</h1>
<p className="text-base text-muted-foreground"> <p className="text-sm text-muted-foreground sm:text-base">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13). Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> {/* Two-up on phones (date range side by side, then template/warehouse), status full width —
<div className="flex flex-col gap-1.5"> five stacked labelled controls would push the results well below the fold. */}
<Label className="text-base">From</Label> <div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" /> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-sm sm:text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 min-w-0 text-base" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">To</Label> <Label className="text-sm sm:text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" /> <Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 min-w-0 text-base" />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Template</Label> <Label className="text-sm sm:text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}> <Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" /> <SelectValue placeholder="All templates" />
@@ -111,8 +134,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label className="text-base">Warehouse</Label> <Label className="text-sm sm:text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}> <Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" /> <SelectValue placeholder="All warehouses" />
@@ -124,8 +147,8 @@ export default function ProducedItemsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
<Label className="text-base">Status</Label> <Label className="text-sm sm:text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}> <Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue /> <SelectValue />
@@ -175,7 +198,80 @@ export default function ProducedItemsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border"> <>
{/* 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"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -193,7 +289,7 @@ export default function ProducedItemsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.items.map((line) => ( {pageItems.map((line) => (
<TableRow <TableRow
key={line.runId} key={line.runId}
className="cursor-pointer" className="cursor-pointer"
@@ -236,6 +332,14 @@ export default function ProducedItemsPage() {
</TableFooter> </TableFooter>
</Table> </Table>
</div> </div>
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</>
)} )}
</div> </div>
) )
@@ -140,7 +140,7 @@ export function RunActions({
return ( return (
<> <>
<div className="flex flex-wrap gap-2"> <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}> <Button variant="outline" onClick={openLeftover} disabled={returnable.length === 0}>
<Undo2 className="size-4" /> <Undo2 className="size-4" />
Return leftover Return leftover
@@ -19,6 +19,7 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors" import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors"
import { import {
RunStageCardBody,
RunHeaderNodeComponent, RunHeaderNodeComponent,
RunStageNodeComponent, RunStageNodeComponent,
type RunHeaderData, type RunHeaderData,
@@ -50,20 +51,8 @@ const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeCom
*/ */
const HEADER_GAP_X = 240 const HEADER_GAP_X = 240
function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } { /** Card data per stage, keyed by runStageId — shared by the canvas nodes and the phone list. */
const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0 function buildStageData(run: ProductionRunGraph): Map<number, RunStageData> {
const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0
const nodes: Node[] = [
{
id: "header",
type: "runHeader",
position: { x: minX - HEADER_GAP_X, y: minY },
data: { docNo: run.docNo, templateName: run.templateName, status: run.status } satisfies RunHeaderData,
draggable: false,
},
]
// The stage the operator is expected to act on: the furthest-along actionable one, so a run // 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. // mid-flight highlights the stage in progress rather than the first thing still Waiting.
const actionable = ["InProgress", "Done", "Ready"] as const const actionable = ["InProgress", "Done", "Ready"] as const
@@ -75,13 +64,12 @@ function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
) )
: null : null
for (const stage of run.stages) { return new Map(
run.stages.map((stage) => {
const upstream = stage.inputs.filter((i) => i.source === "Upstream") const upstream = stage.inputs.filter((i) => i.source === "Upstream")
nodes.push({ return [
id: String(stage.runStageId), stage.runStageId,
type: "runStage", {
position: { x: stage.posX, y: stage.posY },
data: {
name: stage.name, name: stage.name,
roleLabel: stage.roleLabel, roleLabel: stage.roleLabel,
state: stage.status, state: stage.status,
@@ -100,6 +88,57 @@ function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } {
availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0), availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0),
isActive: stage.runStageId === activeId, isActive: stage.runStageId === activeId,
} satisfies RunStageData, } 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
const nodes: Node[] = [
{
id: "header",
type: "runHeader",
position: { x: minX - HEADER_GAP_X, y: minY },
data: { docNo: run.docNo, templateName: run.templateName, status: run.status } satisfies RunHeaderData,
draggable: false,
},
]
for (const stage of run.stages) {
nodes.push({
id: String(stage.runStageId),
type: "runStage",
position: { x: stage.posX, y: stage.posY },
data: stageData.get(stage.runStageId)!,
draggable: false, draggable: false,
}) })
} }
@@ -173,10 +212,12 @@ export default function ProductionRunDetailPage() {
}) })
}, []) }, [])
const stageData = useMemo(() => (run ? buildStageData(run) : new Map<number, RunStageData>()), [run])
const { nodes, edges } = useMemo( const { nodes, edges } = useMemo(
() => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }), () => (run ? buildFlow(run, stageData) : { nodes: [] as Node[], edges: [] as Edge[] }),
[run] [run, stageData]
) )
const orderedStages = useMemo(() => (run ? flowOrder(run) : []), [run])
const stageSummary: StageSummary = useMemo(() => { const stageSummary: StageSummary = useMemo(() => {
const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 } const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 }
@@ -213,9 +254,9 @@ export default function ProductionRunDetailPage() {
if (!run) { if (!run) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" /> <Skeleton className="h-10 w-full max-w-64" />
<Skeleton className="h-40 w-full rounded-2xl" /> <Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-[45vh] w-full rounded-2xl" /> <Skeleton className="h-64 w-full rounded-2xl md:h-[45vh]" />
</div> </div>
) )
} }
@@ -292,9 +333,38 @@ export default function ProductionRunDetailPage() {
<StageStatusLegend /> <StageStatusLegend />
</div> </div>
<p className="text-sm text-muted-foreground">Click a stage to open it.</p> <p className="text-sm text-muted-foreground">
<span className="md:hidden">Tap a stage to open it.</span>
<span className="hidden md:inline">Click a stage to open it.</span>
</p>
<div className="h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> {/* Phones get the stages as a list in flow order — the operator on the floor needs to
reach "their" stage in one tap, not pan and pinch around a canvas. */}
<ol className="flex flex-col gap-3 md:hidden">
{orderedStages.map((stage, i) => {
const data = stageData.get(stage.runStageId)!
return (
<li key={stage.runStageId} className="flex gap-3">
<span className="mt-3 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground tabular-nums">
{i + 1}
</span>
<button
type="button"
onClick={() => setSelectedStageId(stage.runStageId)}
className={cn(
"min-w-0 flex-1 rounded-2xl bg-card p-3 text-left shadow-sm ring-2 transition-colors active:bg-muted/50",
selectedStageId === stage.runStageId ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && selectedStageId !== stage.runStageId ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
>
<RunStageCardBody data={data} />
</button>
</li>
)
})}
</ol>
<div className="hidden h-[55vh] min-h-96 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
{mounted ? ( {mounted ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -26,6 +26,9 @@ import { toast } from "@/components/ui/toast"
import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip" import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
// Mobile has no pagination — tapping "Show more" just grows the page size and re-fetches
// from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type StatusFilter = ProductionRunStatus | "All" type StatusFilter = ProductionRunStatus | "All"
@@ -57,6 +60,8 @@ export default function ProductionRunsPage() {
const [templateId, setTemplateId] = useState<number | null>(null) const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([]) const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -85,7 +90,7 @@ export default function ProductionRunsPage() {
productionRunsApi productionRunsApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
templateId: templateId ?? undefined, templateId: templateId ?? undefined,
@@ -99,10 +104,16 @@ export default function ProductionRunsPage() {
setRuns([]) setRuns([])
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
}) })
}, [page, query, status, templateId, warehouseId]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status, templateId, warehouseId])
useEffect(load, [load]) useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
// Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an // Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an
// Inactive template that still has historical runs; the start dialog narrows to Active // Inactive template that still has historical runs; the start dialog narrows to Active
// itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4). // itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4).
@@ -172,11 +183,11 @@ export default function ProductionRunsPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Production Runs</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Runs</h1>
<p className="text-base text-muted-foreground">All manufacturing runs with per-stage progress at a glance.</p> <p className="text-sm text-muted-foreground sm:text-base">All manufacturing runs with per-stage progress at a glance.</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} /> <DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openStartDialog}><PlayCircle className="size-5" />Start Run</Button>} />
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>Start a run</DialogTitle> <DialogTitle>Start a run</DialogTitle>
@@ -266,22 +277,26 @@ export default function ProductionRunsPage() {
</Dialog> </Dialog>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> {/* Search full width, then the three filters as a compact grid on phones (four stacked
<div className="relative flex-1 basis-0"> 56px controls pushed the runs below the fold); one row from lg, where `lg:contents`
lifts the selects into the outer flex row. */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative lg:flex-1 lg:basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no…" placeholder="Search doc no…"
className="h-14 w-full pl-11 text-base" className="h-12 w-full pl-11 text-base sm:h-14"
aria-label="Search runs" aria-label="Search runs"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:contents">
<Select<number | null> <Select<number | null>
value={templateId} value={templateId}
onValueChange={(v) => { setTemplateId(v); setPage(1) }} onValueChange={(v) => { setTemplateId(v); setPage(1) }}
> >
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> <SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
<SelectValue placeholder="All templates" /> <SelectValue placeholder="All templates" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -294,7 +309,7 @@ export default function ProductionRunsPage() {
value={warehouseId} value={warehouseId}
onValueChange={(v) => { setWarehouseId(v); setPage(1) }} onValueChange={(v) => { setWarehouseId(v); setPage(1) }}
> >
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> <SelectTrigger className="h-12! w-full min-w-0 text-base sm:h-14! lg:flex-1 lg:basis-0">
<SelectValue placeholder="All warehouses" /> <SelectValue placeholder="All warehouses" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -304,7 +319,7 @@ export default function ProductionRunsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}> <Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base"> <SelectTrigger className="col-span-2 h-12! w-full text-base sm:col-span-1 sm:h-14! lg:w-48">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -315,6 +330,7 @@ export default function ProductionRunsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
{hasFilters && ( {hasFilters && (
<Button <Button
@@ -426,7 +442,17 @@ export default function ProductionRunsPage() {
})} })}
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && runs.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full md:hidden" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
{pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -233,7 +233,9 @@ export function StageEditorPanel({
return ( return (
<Dialog open onOpenChange={(next) => !next && onClose()}> <Dialog open onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg"> {/* No bare `max-w-lg`: it would override the base `max-w-[calc(100%-2rem)]` and run the
dialog edge to edge on a phone. dvh so mobile browser chrome doesn't hide the bottom. */}
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-h-[85vh] sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Stage editor</DialogTitle> <DialogTitle>Stage editor</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -301,7 +303,7 @@ export function StageEditorPanel({
</SelectContent> </SelectContent>
</Select> </Select>
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input"> <button type="button" onClick={() => removeInput(input.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -404,7 +406,7 @@ export function StageEditorPanel({
/> />
)} )}
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output"> <button type="button" onClick={() => removeOutput(output.key)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -462,7 +464,7 @@ export function StageEditorPanel({
className="h-8 flex-1 text-sm" className="h-8 flex-1 text-sm"
/> />
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field"> <button type="button" onClick={() => removeField(field.localId)} className="-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
)} )}
@@ -24,14 +24,15 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
disconnected && "opacity-40" disconnected && "opacity-40"
)} )}
> >
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" /> {/* Handles are finger-sized below md: dragging a connection starts from them. */}
<Handle type="target" position={Position.Left} className="bg-primary! size-4! md:size-2.5!" />
{selected && data.onDelete && ( {selected && data.onDelete && (
<button <button
type="button" type="button"
aria-label="Delete stage" aria-label="Delete stage"
title="Delete stage" title="Delete stage"
className="nodrag absolute -top-2.5 -right-2.5 flex size-5 items-center justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90" className="nodrag absolute -top-3 -right-3 flex size-7 items-center md:-top-2.5 md:-right-2.5 md:size-5 justify-center rounded-full bg-destructive text-white shadow-sm hover:bg-destructive/90"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
data.onDelete?.() data.onDelete?.()
@@ -60,7 +61,7 @@ function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) {
{disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>} {disconnected && <p className="mt-1.5 text-xs font-medium text-warning">Disconnected</p>}
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" /> <Handle type="source" position={Position.Right} className="bg-primary! size-4! md:size-2.5!" />
</div> </div>
) )
} }
@@ -643,7 +643,7 @@ function TemplateBuilderContent() {
if (!loaded) { if (!loaded) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" /> <Skeleton className="h-10 w-full max-w-72" />
<Skeleton className="h-[60vh] w-full rounded-2xl" /> <Skeleton className="h-[60vh] w-full rounded-2xl" />
</div> </div>
) )
@@ -661,12 +661,15 @@ function TemplateBuilderContent() {
} }
return ( return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4"> // Phones stack naturally and scroll the page; the canvas gets its own fixed height below.
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> // Only from lg up does the builder fill the viewport with the canvas taking the remainder —
<div className="flex items-center gap-3"> // on a short screen the fields and issue list would otherwise squeeze it to nothing.
<div> <div className="flex flex-col gap-4 lg:h-[calc(100vh-8rem)]">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1> <div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h1 className="min-w-0 wrap-break-word text-xl font-bold text-foreground sm:text-2xl">{name || "Untitled template"}</h1>
{graph ? ( {graph ? (
<Badge <Badge
variant="outline" variant="outline"
@@ -683,16 +686,17 @@ function TemplateBuilderContent() {
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-base text-muted-foreground"> <p className="text-sm text-muted-foreground sm:text-base">
{stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection {stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection
{edges.length === 1 ? "" : "s"} {edges.length === 1 ? "" : "s"}
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> {/* Two-up grid on phones so every action is a full-width tap target; a single row from sm. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
{!locked && ( {!locked && (
<> <>
<Button type="button" variant="outline" onClick={addStage}> <Button type="button" variant="outline" className="col-span-2 sm:col-span-1" onClick={addStage}>
<Plus className="size-5" /> <Plus className="size-5" />
Add Stage Add Stage
</Button> </Button>
@@ -710,6 +714,7 @@ function TemplateBuilderContent() {
<Button <Button
type="button" type="button"
variant={graph.status === "Active" ? "destructive" : "success"} variant={graph.status === "Active" ? "destructive" : "success"}
className={cn(locked && "col-span-2 sm:col-span-1")}
onClick={handleToggleStatus} onClick={handleToggleStatus}
disabled={togglingStatus} disabled={togglingStatus}
> >
@@ -717,7 +722,7 @@ function TemplateBuilderContent() {
</Button> </Button>
)} )}
{!locked && ( {!locked && (
<Button type="button" onClick={handleSave} disabled={saving || conflict}> <Button type="button" className={cn(!graph && "col-span-2 sm:col-span-1")} onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : isNew ? "Create" : "Save"} {saving ? "Saving…" : isNew ? "Create" : "Save"}
</Button> </Button>
@@ -725,7 +730,7 @@ function TemplateBuilderContent() {
</div> </div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-[10rem_1fr_1fr]"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-[10rem_1fr_1fr]">
<Field> <Field>
<FieldLabel htmlFor="tpl-code">Code</FieldLabel> <FieldLabel htmlFor="tpl-code">Code</FieldLabel>
<Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" /> <Input id="tpl-code" value={code} disabled={locked} onChange={(e) => setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" />
@@ -734,7 +739,7 @@ function TemplateBuilderContent() {
<FieldLabel htmlFor="tpl-name">Name</FieldLabel> <FieldLabel htmlFor="tpl-name">Name</FieldLabel>
<Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" /> <Input id="tpl-name" value={name} disabled={locked} onChange={(e) => setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" />
</Field> </Field>
<Field> <Field className="sm:col-span-2 lg:col-span-1">
<FieldLabel htmlFor="tpl-description">Description</FieldLabel> <FieldLabel htmlFor="tpl-description">Description</FieldLabel>
<Input <Input
id="tpl-description" id="tpl-description"
@@ -786,7 +791,7 @@ function TemplateBuilderContent() {
</div> </div>
)} )}
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> <div className="h-[65dvh] min-h-80 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 lg:h-auto lg:min-h-0 lg:flex-1">
{mounted && ( {mounted && (
<ReactFlow <ReactFlow
nodes={displayNodes} nodes={displayNodes}
@@ -806,7 +811,8 @@ function TemplateBuilderContent() {
> >
<Background /> <Background />
<Controls showInteractive={!locked} /> <Controls showInteractive={!locked} />
<MiniMap pannable zoomable /> {/* Covers too much of a phone-sized canvas to be worth it. */}
<MiniMap pannable zoomable className="hidden! md:block!" />
</ReactFlow> </ReactFlow>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react" import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react"
import "@xyflow/react/dist/style.css" import "@xyflow/react/dist/style.css"
@@ -9,6 +10,7 @@ import { LayoutTemplate, Plus, Search } from "lucide-react"
import { productionTemplatesApi } from "@/lib/api/production-templates" import { productionTemplatesApi } from "@/lib/api/production-templates"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductionTemplateSummary, TemplateStatus } from "@/types/production" import { ProductionTemplateSummary, TemplateStatus } from "@/types/production"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { import {
@@ -29,6 +31,9 @@ import { Skeleton } from "@/components/ui/skeleton"
type StatusFilter = TemplateStatus | "All" type StatusFilter = TemplateStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
// Mobile has no pagination — tapping "Show more" just grows the page size and re-fetches
// from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent } const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent }
@@ -92,6 +97,8 @@ export default function ProductionTemplatesPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -119,7 +126,7 @@ export default function ProductionTemplatesPage() {
productionTemplatesApi productionTemplatesApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
}) })
@@ -131,10 +138,16 @@ export default function ProductionTemplatesPage() {
setTemplates([]) setTemplates([])
setLoadError(errorMessage(err)) setLoadError(errorMessage(err))
}) })
}, [page, query, status]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, status])
useEffect(load, [load]) useEffect(load, [load])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const hasFilters = query.length > 0 || status !== "All" const hasFilters = query.length > 0 || status !== "All"
const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates]) const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates])
@@ -174,11 +187,11 @@ export default function ProductionTemplatesPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Production Templates</h1> <h1 className="text-xl font-bold text-foreground sm:text-2xl">Production Templates</h1>
<p className="text-base text-muted-foreground">Every production line, stage by stage. Click a line to open its builder.</p> <p className="text-sm text-muted-foreground sm:text-base">Every production line, stage by stage. Tap a line to open its builder.</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} /> <DialogTrigger render={<Button size="lg" className="w-full sm:w-auto" onClick={openCreateDialog}><Plus className="size-5" />New Template</Button>} />
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New template</DialogTitle> <DialogTitle>New template</DialogTitle>
@@ -253,7 +266,7 @@ export default function ProductionTemplatesPage() {
)} )}
{templates === null ? ( {templates === null ? (
<Skeleton className="h-[70vh] min-h-105 w-full rounded-2xl" /> <Skeleton className="h-64 w-full rounded-2xl md:h-[70vh] md:min-h-105" />
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<LayoutTemplate className="size-12 text-muted-foreground" /> <LayoutTemplate className="size-12 text-muted-foreground" />
@@ -263,7 +276,62 @@ export default function ProductionTemplatesPage() {
</div> </div>
) : ( ) : (
<> <>
<div className="h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> {/* Phones get a list: a pan/zoom canvas of wide horizontal lines is unusable on a
narrow touch screen, so each line reads top to bottom instead. */}
<div className="flex flex-col gap-3 md:hidden">
{templates.map((t) => (
<Link
key={t.templateId}
href={`/dashboard/production/templates/${t.templateId}`}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm active:bg-muted/50"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-muted-foreground">{t.code}</p>
<p className="truncate font-semibold text-foreground">{t.name}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</span>
{t.activeRunCount > 0 && (
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
{t.activeRunCount} in progress
</span>
)}
</div>
</div>
{t.stageNames.length > 0 ? (
<ol className="flex flex-col gap-1.5 border-t pt-3">
{t.stageNames.map((stageName, i) => (
<li key={i} className="flex items-center gap-2.5 text-sm">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary tabular-nums">
{i + 1}
</span>
<span className="truncate text-foreground">{stageName}</span>
</li>
))}
</ol>
) : (
<p className="border-t pt-3 text-sm text-muted-foreground">No stages yet.</p>
)}
</Link>
))}
{pagination && templates.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
<div className="hidden h-[70vh] min-h-105 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10 md:block">
{mounted ? ( {mounted ? (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -287,7 +355,11 @@ export default function ProductionTemplatesPage() {
)} )}
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -193,12 +193,12 @@ export default function ItemDetailPage() {
running += row.direction === "In" ? row.qtyBase : -row.qtyBase running += row.direction === "In" ? row.qtyBase : -row.qtyBase
points.push({ date: row.createdAt, qty: running }) points.push({ date: row.createdAt, qty: running })
} }
// Thin to at most ~40 points so the chart stays legible on a long history. // Running totals are computed over the full history above so they stay accurate —
const step = Math.max(1, Math.ceil(points.length / 40)) // only the most recent 7 points are kept for display.
return points.filter((_, i) => i % step === 0 || i === points.length - 1) return points.slice(-7)
}, [sortedLedger]) }, [sortedLedger])
/** Qty in vs qty out per month, last 6 months with activity. */ /** Qty in vs qty out per month, last 5 months with activity. */
const monthlyActivity = useMemo(() => { const monthlyActivity = useMemo(() => {
const byMonth = new Map<string, { in: number; out: number }>() const byMonth = new Map<string, { in: number; out: number }>()
for (const row of sortedLedger) { for (const row of sortedLedger) {
@@ -208,11 +208,22 @@ export default function ItemDetailPage() {
else bucket.out += row.qtyBase else bucket.out += row.qtyBase
byMonth.set(key, bucket) byMonth.set(key, bucket)
} }
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6) return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
}, [sortedLedger]) }, [sortedLedger])
// Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the // Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the
// blessed way to do it once (rather than on every render) is a one-time init function. // blessed way to do it once (rather than on every render) is a one-time init function.
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mql = window.matchMedia("(max-width: 639px)")
setIsMobile(mql.matches)
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
const [cutoff30] = useState(() => Date.now() - 30 * 24 * 60 * 60 * 1000) const [cutoff30] = useState(() => Date.now() - 30 * 24 * 60 * 60 * 1000)
const salesQty30d = useMemo( const salesQty30d = useMemo(
() => () =>
@@ -408,7 +419,14 @@ export default function ItemDetailPage() {
) : stockTrend.length > 1 ? ( ) : stockTrend.length > 1 ? (
<LineChart <LineChart
labels={stockTrend.map((p) => new Date(p.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }))} labels={stockTrend.map((p) => new Date(p.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
datasets={[{ label: `On hand (${uomName(item.baseUomId)})`, data: stockTrend.map((p) => p.qty) }]} datasets={[
{
label: `On hand (${uomName(item.baseUomId)})`,
data: stockTrend.map((p) => p.qty),
borderColor: "rgba(225,29,72,1)",
backgroundColor: "rgba(225,29,72,0.15)",
},
]}
/> />
) : ( ) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No stock movements recorded yet.</p> <p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No stock movements recorded yet.</p>
@@ -424,11 +442,35 @@ export default function ItemDetailPage() {
<Skeleton className="h-56 w-full" /> <Skeleton className="h-56 w-full" />
) : monthlyActivity.length > 0 ? ( ) : monthlyActivity.length > 0 ? (
<BarChart <BarChart
labels={monthlyActivity.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" }))} // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
datasets={[ // activity, Chart.js would otherwise center the lone category across the full
{ label: "Purchased", data: monthlyActivity.map(([, v]) => v.in), backgroundColor: "rgba(34,197,94,0.6)" }, // width. Trailing blank slots keep the axis/gridlines spanning the full card
{ label: "Sold", data: monthlyActivity.map(([, v]) => v.out), backgroundColor: "rgba(239,68,68,0.6)" }, // while the real bars sit at the start. Skipped on mobile — there the bars
// should stretch to fill the narrower card instead.
labels={[
...monthlyActivity.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill("")),
]} ]}
datasets={[
{
label: "Purchased",
data: [
...monthlyActivity.map(([, v]) => v.in),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
],
backgroundColor: "rgba(34,197,94,0.6)",
},
{
label: "Sold",
data: [
...monthlyActivity.map(([, v]) => v.out),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyActivity.length)).fill(null)),
],
backgroundColor: "rgba(239,68,68,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
/> />
) : ( ) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No activity recorded yet.</p> <p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No activity recorded yet.</p>
@@ -444,6 +486,34 @@ export default function ItemDetailPage() {
) : recentSales.length === 0 ? ( ) : recentSales.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No sales recorded yet.</p> <p className="py-6 text-center text-sm text-muted-foreground">No sales recorded yet.</p>
) : ( ) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{recentSales.map((row) => {
const href = docLink(row.sourceDocType, row.sourceDocId)
return (
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
{href ? (
<Link href={href} className="truncate font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
) : (
<span className="truncate font-medium text-foreground">{docLabel(row.sourceDocType, row.sourceDocId)}</span>
)}
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Qty {row.qtyBase.toLocaleString()}</span>
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm"> <Table className="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -475,6 +545,8 @@ export default function ItemDetailPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</>
)} )}
</div> </div>
@@ -485,6 +557,27 @@ export default function ItemDetailPage() {
) : recentGrns.length === 0 ? ( ) : recentGrns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No goods received yet.</p> <p className="py-6 text-center text-sm text-muted-foreground">No goods received yet.</p>
) : ( ) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{recentGrns.map((row) => (
<div key={row.ledgerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<Link href={docLink(row.sourceDocType, row.sourceDocId) ?? "#"} className="truncate font-medium text-primary hover:underline">
{docLabel(row.sourceDocType, row.sourceDocId)}
</Link>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Qty {row.qtyBase.toLocaleString()}</span>
<span className="font-medium text-foreground">LKR {row.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm"> <Table className="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -509,6 +602,8 @@ export default function ItemDetailPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</>
)} )}
</div> </div>
</div> </div>
@@ -214,14 +214,15 @@ function BrandPanel() {
key={b.brandId} key={b.brandId}
onDoubleClick={() => openEdit(b)} onDoubleClick={() => openEdit(b)}
title="Tick to activate/deactivate — double-click to rename" title="Tick to activate/deactivate — double-click to rename"
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50" className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
> >
<Checkbox <Checkbox
checked={b.status === "Active"} checked={b.status === "Active"}
onCheckedChange={() => handleToggleActive(b)} onCheckedChange={() => handleToggleActive(b)}
disabled={togglingId === b.brandId} disabled={togglingId === b.brandId}
className="shrink-0"
/> />
<span className="font-medium">{b.name}</span> <span className="truncate font-medium">{b.name}</span>
</label> </label>
))} ))}
</div> </div>
@@ -356,14 +357,15 @@ function UomPanel() {
<label <label
key={u.uomId} key={u.uomId}
title="Tick to activate/deactivate" title="Tick to activate/deactivate"
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50" className="flex min-w-0 items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
> >
<Checkbox <Checkbox
checked={u.status === "Active"} checked={u.status === "Active"}
onCheckedChange={() => handleToggleActive(u)} onCheckedChange={() => handleToggleActive(u)}
disabled={togglingId === u.uomId} disabled={togglingId === u.uomId}
className="shrink-0"
/> />
<span className="font-medium">{u.name}</span> <span className="truncate font-medium">{u.name}</span>
</label> </label>
))} ))}
</div> </div>
@@ -166,6 +166,71 @@ export default function CategorySubCategoriesPage() {
)} )}
{!error && subCategories !== null && subCategories.length > 0 && ( {!error && subCategories !== null && subCategories.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{subCategories.map((s) => (
<div key={s.subCategoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{s.name}</p>
<p className="text-sm text-muted-foreground">#{s.subCategoryId}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{s.status}
</Badge>
</div>
<div className="border-t pt-3 text-sm">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(s.createdAt).toLocaleDateString()}</p>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
disabled={togglingId === s.subCategoryId}
/>
}
>
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={s.status === "Active" ? "destructive" : "success"}
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
description={
s.status === "Active"
? "It stays on existing items but cannot be assigned to new ones."
: "It becomes selectable again."
}
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(s)}
/>
</AlertDialog>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -231,6 +296,9 @@ export default function CategorySubCategoriesPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
</>
)} )}
</div> </div>
) )
@@ -264,6 +264,88 @@ export default function CategoriesPage() {
{!error && categories !== null && categories.length > 0 && ( {!error && categories !== null && categories.length > 0 && (
<> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{sortedCategories!.map((c) => (
<div key={c.categoryId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{c.name}</p>
<p className="text-sm text-muted-foreground">#{c.categoryId}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{c.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
{config?.subcategoriesEnabled && (
<div>
<p className="text-muted-foreground">Subcategories</p>
<p className="font-medium text-foreground">{subCategoryCounts[c.categoryId] ?? "—"}</p>
</div>
)}
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
)}
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${c.name}`} onClick={() => openEditDialog(c)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -359,6 +441,8 @@ export default function CategoriesPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
</> </>
@@ -9,6 +9,7 @@ import {
ArrowUp, ArrowUp,
ArrowUpDown, ArrowUpDown,
Download, Download,
Eye,
ListTree, ListTree,
Package, Package,
Pencil, Pencil,
@@ -24,6 +25,7 @@ import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { downloadCsv } from "@/lib/csv-export" import { downloadCsv } from "@/lib/csv-export"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { EntityStatus, PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data" import { Brand, Category, ItemOverview, ProductConfig } from "@/types/master-data"
@@ -41,7 +43,7 @@ type StatusFilter = EntityStatus | "All"
type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate" type SortKey = "sku" | "name" | "stockValue" | "salesQtyLast30Days" | "lastPurchaseDate"
type SortOrder = "asc" | "desc" type SortOrder = "asc" | "desc"
const PAGE_SIZE = 25 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
const COLUMN_OPTIONS = [ const COLUMN_OPTIONS = [
{ key: "categoryBrand", label: "Category / Brand" }, { key: "categoryBrand", label: "Category / Brand" },
@@ -70,6 +72,7 @@ function SortableHeader({ label, active, order, onClick }: { label: string; acti
export default function ItemsPage() { export default function ItemsPage() {
const router = useRouter() const router = useRouter()
const isMobile = useIsMobile()
const [items, setItems] = useState<ItemOverview[] | null>(null) const [items, setItems] = useState<ItemOverview[] | null>(null)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [brands, setBrands] = useState<Brand[]>([]) const [brands, setBrands] = useState<Brand[]>([])
@@ -82,6 +85,8 @@ export default function ItemsPage() {
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All") const [categoryId, setCategoryId] = useState<number | "All">("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [loadingMore, setLoadingMore] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set()) const [selected, setSelected] = useState<Set<number>>(new Set())
const [sortKey, setSortKey] = useState<SortKey>("sku") const [sortKey, setSortKey] = useState<SortKey>("sku")
@@ -93,27 +98,49 @@ export default function ItemsPage() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query, status, categoryId]) // A new filter — or switching between mobile ("Show more" appends) and desktop (numbered
// pages) — restarts at page 1. Reset during render rather than in an effect.
const resetKey = `${query}|${status}|${categoryId}|${isMobile}`
const [prevResetKey, setPrevResetKey] = useState(resetKey)
if (prevResetKey !== resetKey) {
setPrevResetKey(resetKey)
setPage(1)
}
function load() { useEffect(() => {
setError(null) let cancelled = false
// Mobile "Show more" appends the next server page to what's already listed.
const append = isMobile && page > 1
itemsApi itemsApi
.overview({ .overview({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId, categoryId: categoryId === "All" ? undefined : categoryId,
}) })
.then((res) => { .then((res) => {
setItems(res.items) if (cancelled) return
setError(null)
setItems((prev) => (append && prev ? [...prev, ...res.items] : res.items))
setPagination(res.pagination) setPagination(res.pagination)
setSelected(new Set()) if (!append) setSelected(new Set())
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
.finally(() => {
if (!cancelled) setLoadingMore(false)
})
return () => {
cancelled = true
} }
}, [page, pageSize, query, status, categoryId, isMobile])
useEffect(load, [page, query, status, categoryId]) function showMore() {
setLoadingMore(true)
setPage((p) => p + 1)
}
useEffect(() => { useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
@@ -208,7 +235,7 @@ export default function ItemsPage() {
<h1 className="text-2xl font-bold text-foreground">Items</h1> <h1 className="text-2xl font-bold text-foreground">Items</h1>
<p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p> <p className="text-base text-muted-foreground">Stock value, last purchase and sales activity at a glance — click a row for its full profile.</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Ruler className="size-5" /> <Ruler className="size-5" />
UOMs UOMs
@@ -296,7 +323,115 @@ export default function ItemsPage() {
{!error && sortedItems !== null && sortedItems.length > 0 && ( {!error && sortedItems !== null && sortedItems.length > 0 && (
<> <>
<div className="overflow-x-auto rounded-xl border"> <div className="flex flex-col gap-3 md:hidden">
{sortedItems.map((item) => (
<div
key={item.itemId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/products/${item.itemId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/products/${item.itemId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<Checkbox
checked={selected.has(item.itemId)}
onCheckedChange={(c) => toggleRow(item.itemId, c === true)}
aria-label={`Select ${item.sku}`}
data-no-row-nav
className="mt-0.5 shrink-0"
/>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Package className="size-4 text-primary" />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{item.name}</p>
<p className="text-sm text-muted-foreground">{item.sku}</p>
</div>
</div>
{showCol("status") && (
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{item.status}
</Badge>
)}
</div>
{showCol("categoryBrand") && (
<p className="text-sm text-muted-foreground">
{categoryName(item.categoryId)}
{config?.brandsEnabled && ` · ${brandName(item.brandId)}`}
</p>
)}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
{showCol("stockValue") && (
<div>
<p className="text-muted-foreground">Stock Value</p>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">{formatMoney(item.stockValue)}</span>
{item.isLowStock && (
<Badge variant="outline" className="h-5 gap-1 border-warning/30 bg-warning/10 px-1.5 text-xs text-warning">
<AlertTriangle className="size-3" />
Low
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{item.stockQty.toLocaleString()} on hand</p>
</div>
)}
{showCol("sales30d") && (
<div>
<p className="text-muted-foreground">Sales (30d)</p>
<p className="font-medium text-foreground">{item.salesQtyLast30Days.toLocaleString()}</p>
</div>
)}
{showCol("lastPurchase") && (
<div>
<p className="text-muted-foreground">Last Purchase</p>
{item.lastPurchaseDate ? (
<>
<p className="font-medium text-foreground">{new Date(item.lastPurchaseDate).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{item.lastSupplierName ?? "—"}</p>
</>
) : (
<p className="text-foreground">—</p>
)}
</div>
)}
</div>
<Link
href={`/dashboard/products/${item.itemId}`}
data-no-row-nav
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-3.5" />
Edit
</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">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -386,13 +521,24 @@ export default function ItemsPage() {
</TableCell> </TableCell>
)} )}
<TableCell className="px-3 py-3.5" data-no-row-nav> <TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${item.sku}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/products/${item.itemId}`} href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`} aria-label={`Edit ${item.sku}`}
title="Edit"
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -400,7 +546,16 @@ export default function ItemsPage() {
</Table> </Table>
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -92,7 +92,7 @@ export default function ProductSettingsPage() {
{!error && !config && <Skeleton className="h-80 w-full" />} {!error && !config && <Skeleton className="h-80 w-full" />}
{!error && config && ( {!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6"> <div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Package className="size-5 text-primary" /> <Package className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2> <h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Percent, Plus, Search } from "lucide-react" import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, Pencil, Percent, Plus, Search } from "lucide-react"
import { taxRatesApi } from "@/lib/api/tax-rates" import { taxRatesApi } from "@/lib/api/tax-rates"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -345,39 +346,7 @@ export default function TaxRatesPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && pagination.totalPages > 1 && ( {pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react" import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -154,11 +154,11 @@ export default function GrnDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{grn.status !== "Draft" && ( {grn.status !== "Draft" && (
<Link <Link
href="/dashboard/ledgers/general-ledger" href="/dashboard/ledgers/general-ledger"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
title="View this GRN's postings in the General Ledger report" title="View this GRN's postings in the General Ledger report"
> >
<BookOpen className="size-5" /> <BookOpen className="size-5" />
@@ -166,7 +166,7 @@ export default function GrnDetailPage() {
</Link> </Link>
)} )}
{grn.status === "Draft" && ( {grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}> <Button size="lg" className="w-full sm:w-auto" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" /> <PackageCheck className="size-5" />
{confirming ? "Confirming…" : "Confirm GRN"} {confirming ? "Confirming…" : "Confirm GRN"}
</Button> </Button>
@@ -193,7 +193,110 @@ export default function GrnDetailPage() {
</div> </div>
)} )}
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{grn.lines.map((line) => {
const item = itemFor(line.itemId)
return (
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate font-medium text-foreground">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</p>
{item?.warranty === "Warranty" && <Badge variant="outline" className="shrink-0 text-xs">Warranty</Badge>}
</div>
{line.warrantyNumbers.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
</div>
<HoldStatusBadge status={line.holdStatus} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">UOM / Bin</p>
<p className="font-medium text-foreground">{baseUomLabel(items, uoms, line.itemId)} · {binFor(line.binId)}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{line.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Unit cost</p>
<p className="font-medium text-foreground">
{line.unitCost.toFixed(2)}
{line.poUnitPrice !== null && line.priceVariance !== 0 && (
<span className="block text-xs text-warning">
PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)}
</span>
)}
</p>
</div>
<div>
<p className="text-muted-foreground">Disc % / Net cost</p>
<p className="font-medium text-foreground">{line.discountPct.toFixed(2)}% / {line.netUnitCost.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Received value</p>
<p className="font-medium text-foreground">{line.receivedValue.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">VAT</p>
<p className="font-medium text-foreground">{line.vatAmount.toFixed(2)} ({line.vatPct.toFixed(2)}%)</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Line total</p>
<p className="font-semibold text-foreground">{line.lineTotal.toFixed(2)}</p>
</div>
</div>
{grn.status === "Confirmed" && (
<div className="border-t pt-3">
{line.holdStatus === "OnHold" ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="success"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Release")}
>
<ShieldAlert className="size-4" />
Release
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Reject")}
>
<XCircle className="size-4" />
Reject
</Button>
</div>
) : line.holdStatus === "Rejected" ? (
<Link
href={`/dashboard/procurement/purchase-returns/new?grnId=${grn.grnId}&grnLineId=${line.grnLineId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<PackageX className="size-4" />
Create Return
</Link>
) : (
<span className="text-sm text-muted-foreground">No actions</span>
)}
</div>
)}
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -339,9 +442,9 @@ export default function GrnDetailPage() {
<span className="text-sm text-muted-foreground">This GRN hasn&apos;t been added to a purchase invoice yet.</span> <span className="text-sm text-muted-foreground">This GRN hasn&apos;t been added to a purchase invoice yet.</span>
<Link <Link
href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`} href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`}
className={cn(buttonVariants({ size: "sm" }))} className={cn(buttonVariants({ size: "sm" }), "rounded-full")}
> >
<FileText className="size-4" /> <Plus className="size-4" />
Create Purchase Invoice Create Purchase Invoice
</Link> </Link>
</div> </div>
@@ -834,7 +834,190 @@ export default function NewGrnPage() {
{poLoading && <Skeleton className="h-24 w-full" />} {poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lineTab === "lines" && lines.length > 0 && ( {!poLoading && lineTab === "lines" && lines.length > 0 && (
<div className="overflow-x-auto"> <>
{/* Mobile: stacked editable cards. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
const c = computeLine(line)
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item</Label>
{line.poLineId ? (
<div className="flex h-11 items-center gap-2 text-sm">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.sku} — {i.name}
{i.warranty === "Warranty" ? " (Warranty)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
)}
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</div>
<Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">UOM</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</div>
<div>
<Label className="text-sm">Bin</Label>
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-sm">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{item?.trackingMode === "Batch" && (
<div>
<Label className="text-sm">Batch no</Label>
<Input
value={line.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
placeholder="Batch no"
className="h-11 text-sm"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Qty</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-sm"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
<div>
<Label className="text-sm">Unit cost</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-sm"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
<p className="mt-1 text-xs text-warning">PO price {line.poUnitPrice.toFixed(2)}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
disabled={headerDiscountPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</div>
<div>
<Label className="text-sm">VAT %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
disabled={headerVatPct.trim() !== ""}
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</div>
</div>
<div>
<Label className="text-sm">Hold status</Label>
<Select<HoldStatus> value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}>
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-sm">Available</SelectItem>
<SelectItem value="OnHold" className="text-sm">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</div>
{item?.warranty === "Warranty" && (
<Button type="button" variant="outline" size="sm" onClick={() => setLineTab("warranty")}>
<ShieldCheck className="size-4" />
Add warranty numbers
</Button>
)}
{errors.warrantyNumbers && <FieldError errors={[{ message: errors.warrantyNumbers }]} />}
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Line total</span>
<div className="text-right">
<span className="font-semibold tabular-nums">{c.lineTotal.toFixed(2)}</span>
<p className="text-xs text-muted-foreground">net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}</p>
</div>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm"> <Table className="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -1044,6 +1227,7 @@ export default function NewGrnPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</>
)} )}
{!poLoading && lineTab === "warranty" && ( {!poLoading && lineTab === "warranty" && (
@@ -1179,11 +1363,11 @@ export default function NewGrnPage() {
)} )}
{lineTab === "lines" && ( {lineTab === "lines" && (
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"} {submitting ? "Creating…" : "Create GRN"}
</Button> </Button>
</div> </div>
@@ -3,11 +3,14 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Clock, PackageSearch, Plus, Search } from "lucide-react" import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn" import { GrnStatus, GrnSummary } from "@/types/grn"
import { Vendor, Warehouse } from "@/types/master-data"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
@@ -31,7 +34,10 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All" type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function GrnListPage() { export default function GrnListPage() {
const router = useRouter() const router = useRouter()
@@ -39,6 +45,8 @@ export default function GrnListPage() {
const stats = useDashboardStats() const stats = useDashboardStats()
const [grns, setGrns] = useState<GrnSummary[] | null>(null) const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -46,6 +54,22 @@ export default function GrnListPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
warehousesApi.list({ pageSize: 200 }).then((res) => setWarehouses(res.items)).catch(() => {})
}, [])
function vendorName(id: number) {
const vendor = vendors.find((v) => v.vendorId === id)
return vendor ? `${vendor.code} — ${vendor.name}` : `#${id}`
}
function warehouseName(id: number) {
const warehouse = warehouses.find((w) => w.warehouseId === id)
return warehouse ? `${warehouse.code} — ${warehouse.name}` : `#${id}`
}
// Debounce the search box so typing doesn't refetch on every keystroke. // Debounce the search box so typing doesn't refetch on every keystroke.
@@ -64,7 +88,7 @@ export default function GrnListPage() {
grnsApi grnsApi
.list({ .list({
page, page,
pageSize: PAGE_SIZE, pageSize,
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
@@ -74,9 +98,15 @@ export default function GrnListPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page, query, status, activeBranchId, branchingEnabled]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled])
const hasFilters = query.length > 0 || status !== "All" const hasFilters = query.length > 0 || status !== "All"
@@ -89,7 +119,7 @@ export default function GrnListPage() {
Receive goods against a purchase order, or record a direct receipt. Receive goods against a purchase order, or record a direct receipt.
</p> </p>
</div> </div>
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New GRN New GRN
</Link> </Link>
@@ -142,9 +172,21 @@ export default function GrnListPage() {
)} )}
{!error && grns === null && ( {!error && grns === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -178,16 +220,90 @@ export default function GrnListPage() {
{!error && grns !== null && grns.length > 0 && ( {!error && grns !== null && grns.length > 0 && (
<> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{grns.map((grn) => (
<div
key={grn.grnId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{grn.docNo}</p>
<p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
</div>
<GrnStatusBadge status={grn.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">PO</p>
<p className="font-medium text-foreground" data-no-row-nav>
{grn.poId ? (
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
PO {String(grn.poId).padStart(2, "0")}
</Link>
) : (
"Direct"
)}
</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseName(grn.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Invoice</p>
<p className="font-medium text-foreground" data-no-row-nav>
{grn.status !== "Confirmed" ? (
"—"
) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link>
) : (
"Not invoiced"
)}
</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(grn.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))}
{pagination && grns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="w-40">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">PO</TableHead> <TableHead>PO</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead> <TableHead>Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <TableHead>Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead> <TableHead>Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="w-20 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -195,45 +311,76 @@ export default function GrnListPage() {
return ( return (
<TableRow <TableRow
key={grn.grnId} key={grn.grnId}
className="cursor-pointer hover:bg-muted/40" className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/receiving/grn/${grn.grnId}`) router.push(`/dashboard/receiving/grn/${grn.grnId}`)
}} }}
> >
<TableCell className="px-3 py-3.5"> <TableCell>
<Link <Link
href={`/dashboard/receiving/grn/${grn.grnId}`} href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-medium text-foreground hover:underline" className="font-semibold text-foreground hover:underline"
> >
{grn.docNo} {grn.docNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{grn.poId ?? <span className="text-muted-foreground">Direct</span>}</TableCell> <TableCell className="text-sm text-muted-foreground" data-no-row-nav>
<TableCell className="px-3 py-3.5">{grn.vendorId}</TableCell> {grn.poId ? (
<TableCell className="px-3 py-3.5">{grn.warehouseId}</TableCell> <Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
<TableCell className="px-3 py-3.5"> PO {String(grn.poId).padStart(2, "0")}
</Link>
) : (
<span>Direct</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{warehouseName(grn.warehouseId)}</TableCell>
<TableCell>
<GrnStatusBadge status={grn.status} /> <GrnStatusBadge status={grn.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav> <TableCell data-no-row-nav className="text-sm text-muted-foreground">
{grn.status !== "Confirmed" ? ( {grn.status !== "Confirmed" ? (
<span className="text-sm text-muted-foreground">—</span> <span>—</span>
) : grn.purchaseInvoiceId ? ( ) : grn.purchaseInvoiceId ? (
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="text-sm hover:underline"> <Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`} {grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
</Link> </Link>
) : ( ) : (
<span className="text-sm text-muted-foreground">Not invoiced</span> <span>Not invoiced</span>
)} )}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow> </TableRow>
) )
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -2,12 +2,22 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Save, ShoppingBag, Trash2 } from "lucide-react" import { ArrowLeft, Plus, Save, ShoppingBag } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Combobox } from "@/components/ui/combobox" import { Combobox } from "@/components/ui/combobox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -18,9 +28,15 @@ import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles" import { BundleSaleTemplateSummary, CreateBundleSaleTemplateLineRequest } from "@/types/bundles"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type Line = CreateBundleSaleTemplateLineRequest & { key: string } type Line = CreateBundleSaleTemplateLineRequest & { key: string }
const blankLine = (key: string, warehouseId: number): Line => ({ const blankLine = (key: string, warehouseId: number): Line => ({
@@ -42,13 +58,26 @@ export default function BundleTemplatesPage() {
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [lines, setLines] = useState<Line[]>([]) const [lines, setLines] = useState<Line[]>([])
const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([]) const [templates, setTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
async function refreshTemplates() { async function refreshTemplates() {
const res = await bundleApi.listTemplates({ pageSize: 50 }) const res = await bundleApi.listTemplates({ page, pageSize, q: query || undefined })
setTemplates(res.items) setTemplates(res.items)
setPagination(res.pagination)
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(() => { useEffect(() => {
@@ -70,6 +99,17 @@ export default function BundleTemplatesPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
useEffect(() => {
if (!loading) refreshTemplates().finally(() => setShowMoreLoading(false))
}, [page, pageSize, query])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query])
const itemOptions = useMemo( const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })), () => items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })),
[items] [items]
@@ -84,18 +124,20 @@ export default function BundleTemplatesPage() {
updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 }) updateLine(key, { itemId, unitPrice: item?.sellingFlatPrice ?? 0 })
} }
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`, warehouseId ?? 0)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function resetForm() { function resetForm() {
setTemplateName("") setTemplateName("")
setDescription("") setDescription("")
setLines([blankLine("line-1", warehouseId ?? 0)]) setLines([blankLine("line-1", warehouseId ?? 0)])
setError(null)
}
function openCreateForm() {
resetForm()
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) resetForm()
} }
const componentSubtotal = useMemo( const componentSubtotal = useMemo(
@@ -124,6 +166,7 @@ export default function BundleTemplatesPage() {
}) })
toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`) toast.success("Bundle template saved", `${created.templateCode} is ready to sell.`)
resetForm() resetForm()
setFormOpen(false)
await refreshTemplates() await refreshTemplates()
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
@@ -148,23 +191,30 @@ export default function BundleTemplatesPage() {
<p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p> <p className="text-base text-muted-foreground">Define a reusable bundle of items — once saved, it&apos;s ready to sell from Create Sale.</p>
</div> </div>
</div> </div>
<Button size="sm" onClick={submit} disabled={saving}> <Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<Save className="size-4" /> {saving ? "Saving..." : "Save bundle template"} <DialogTrigger
render={
<Button size="lg" onClick={openCreateForm}>
<Plus className="size-5" />
New Bundle Template
</Button> </Button>
</div> }
/>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader className="items-center text-center">
<DialogTitle>New bundle template</DialogTitle>
<DialogDescription>Template code is generated automatically when you save.</DialogDescription>
</DialogHeader>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null} {error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <FieldGroup>
<h2 className="text-sm font-semibold">Bundle details</h2> <Field>
<p className="mt-1 text-xs text-muted-foreground">Template code is generated automatically when you save.</p> <FieldLabel htmlFor="bt-name">Template name</FieldLabel>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <Input id="bt-name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" />
<div className="space-y-1.5"> </Field>
<Label className="text-xs">Template name</Label> <Field>
<Input value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="e.g. Back to School Combo" /> <FieldLabel htmlFor="bt-warehouse">Warehouse</FieldLabel>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<Select <Select
value={warehouseId ? String(warehouseId) : ""} value={warehouseId ? String(warehouseId) : ""}
onValueChange={(v) => { onValueChange={(v) => {
@@ -173,9 +223,7 @@ export default function BundleTemplatesPage() {
setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId }))) setLines((prev) => prev.map((line) => ({ ...line, warehouseId: nextId ?? line.warehouseId })))
}} }}
> >
<SelectTrigger className="h-9 text-sm"> <SelectTrigger id="bt-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent> <SelectContent>
{warehouses.map((w) => ( {warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}> <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
@@ -184,114 +232,198 @@ export default function BundleTemplatesPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </Field>
<div className="space-y-1.5"> <Field>
<Label className="text-xs">Description (optional)</Label> <FieldLabel htmlFor="bt-description">Description (optional)</FieldLabel>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" /> <Input id="bt-description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Shown to staff picking a bundle to sell" />
</div> </Field>
</div> </FieldGroup>
</section>
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Component items</h2>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add component
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Include</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => ( {lines.map((line) => (
<TableRow key={line.key} className="align-middle"> <FieldGroup key={line.key}>
<TableCell className="px-4 py-2 min-w-80"> <Field>
<FieldLabel>Item</FieldLabel>
<Combobox <Combobox
items={itemOptions} items={itemOptions}
value={line.itemId || null} value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)} onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…" placeholder="Search item…"
/> />
</TableCell> </Field>
<TableCell className="px-4 py-2 min-w-40 text-sm"> <Field>
{baseUomLabel(items, uoms, line.itemId)} <FieldLabel>UOM</FieldLabel>
</TableCell> <Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
<TableCell className="px-4 py-2"> </Field>
<Input type="number" min="0.0001" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> <Field>
</TableCell> <FieldLabel htmlFor={`bt-qty-${line.key}`}>Qty</FieldLabel>
<TableCell className="px-4 py-2"> <Input
<Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="h-9 w-28 text-right font-mono text-sm tabular-nums" /> id={`bt-qty-${line.key}`}
</TableCell> type="number"
<TableCell className="px-4 py-2 text-right"> min="0.0001"
step="1"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel htmlFor={`bt-price-${line.key}`}>Unit price</FieldLabel>
<Input
id={`bt-price-${line.key}`}
type="number"
min="0"
step="0.01"
value={line.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })}
className="font-mono tabular-nums"
/>
</Field>
<Field>
<FieldLabel>Include in bundle</FieldLabel>
<button <button
type="button" type="button"
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })} onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
className={cn( className={cn(
"rounded-full px-3 py-1 text-xs font-medium", "w-fit rounded-full px-3 py-1 text-xs font-medium",
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground" line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
)} )}
> >
{line.includeInBundle ? "Included" : "Excluded"} {line.includeInBundle ? "Included" : "Excluded"}
</button> </button>
</TableCell> </Field>
<TableCell className="px-4 py-2 text-right"> </FieldGroup>
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))} ))}
</TableBody>
</Table> <div className="flex items-center justify-between rounded-xl border border-dashed border-border px-4 py-3 text-sm">
</div>
<div className="flex justify-end border-t border-border px-4 py-3 text-sm">
<span className="text-muted-foreground">Component subtotal</span> <span className="text-muted-foreground">Component subtotal</span>
<span className="ml-2 font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span> <span className="font-semibold tabular-nums">{componentSubtotal.toFixed(2)}</span>
</div> </div>
</section>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving…" : "Save bundle template"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 className="text-sm font-semibold">Bundle templates</h3> <h3 className="text-sm font-semibold">Bundle templates</h3>
<p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an &quot;Add bundle&quot; option on Create Sale.</p> <p className="mt-1 text-xs text-muted-foreground">Every saved bundle shows up here, and as an &quot;Add bundle&quot; option on Create Sale.</p>
</div> </div>
<Button variant="outline" size="sm" onClick={refreshTemplates}> <Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshTemplates}>
Refresh Refresh
</Button> </Button>
</div> </div>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm"> <Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search template name or code…"
className="mt-3 h-10 w-full max-w-sm text-sm"
aria-label="Search bundle templates"
/>
{templates.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{templates.map((t) => (
<div key={t.bundleSaleTemplateId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{t.templateName}</p>
<p className="text-sm text-muted-foreground">{t.templateCode}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</div>
{t.description && <p className="text-sm text-muted-foreground">{t.description}</p>}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Lines</p>
<p className="font-medium text-foreground">{t.lineCount}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(t.createdAt).toLocaleDateString()}</p>
</div>
</div>
<Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
>
<ShoppingBag className="size-4" />
Sell
</Link>
</div>
))}
{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>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-4 text-sm">Template</TableHead> <TableHead className="w-48">Template</TableHead>
<TableHead className="h-12 px-4 text-sm">Description</TableHead> <TableHead>Description</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead> <TableHead className="text-right">Lines</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead> <TableHead>Created</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-32 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{templates.length > 0 ? ( {templates.map((t) => (
templates.map((t) => ( <TableRow key={t.bundleSaleTemplateId} className="align-top hover:bg-muted/20">
<TableRow key={t.bundleSaleTemplateId} className="hover:bg-muted/40"> <TableCell>
<TableCell className="px-4 py-3.5"> <div className="font-semibold text-foreground">{t.templateName}</div>
<div className="font-medium">{t.templateName}</div>
<div className="text-xs text-muted-foreground">{t.templateCode}</div> <div className="text-xs text-muted-foreground">{t.templateCode}</div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5">{t.description ?? "—"}</TableCell> <TableCell className="text-sm text-muted-foreground">{t.description ?? "—"}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{t.lineCount}</TableCell>
<TableCell className="px-4 py-3.5">{t.status}</TableCell> <TableCell>
<TableCell className="px-4 py-3.5 text-right"> <Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
t.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(t.createdAt).toLocaleString()}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.updatedAt ? new Date(t.updatedAt).toLocaleString() : "—"}</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Link <Link
href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`} href={`/dashboard/sales/invoices/new?bundleTemplateId=${t.bundleSaleTemplateId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
@@ -299,19 +431,31 @@ export default function BundleTemplatesPage() {
<ShoppingBag className="size-4" /> <ShoppingBag className="size-4" />
Sell Sell
</Link> </Link>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))}
) : (
<TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">
No bundle templates yet — save one above.
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No bundle templates yet — create one above.
</div>
)}
</section> </section>
</div> </div>
) )
@@ -236,7 +236,24 @@ export default function SalesDayEndPage() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3> <h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
<div className="overflow-x-auto"> {report.itemBreakdown.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No lines.</p>
) : (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{report.itemBreakdown.map((line) => (
<div key={line.itemId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
<span className="min-w-0 truncate">{line.sku} — {line.name}</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{line.qty} × {line.revenue.toFixed(2)}
</span>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm"> <Table className="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -246,22 +263,18 @@ export default function SalesDayEndPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{report.itemBreakdown.length === 0 ? ( {report.itemBreakdown.map((line) => (
<TableRow>
<TableCell colSpan={3} className="px-3 py-6 text-center text-sm text-muted-foreground">No lines.</TableCell>
</TableRow>
) : (
report.itemBreakdown.map((line) => (
<TableRow key={line.itemId}> <TableRow key={line.itemId}>
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell> <TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell> <TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell> <TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
</TableRow> </TableRow>
)) ))}
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</>
)}
</div> </div>
{report.slipNumbers.length > 0 && ( {report.slipNumbers.length > 0 && (
@@ -2,11 +2,22 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Eraser, Pencil, Plus, Save, Trash2, X } from "lucide-react" import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Combobox } from "@/components/ui/combobox" import { Combobox } from "@/components/ui/combobox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -19,10 +30,16 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { Customer } from "@/types/customers" import { Customer } from "@/types/customers"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users" import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales" import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type Line = CreateSalesSlipLineRequest & { key: string } type Line = CreateSalesSlipLineRequest & { key: string }
type FreeIssueRow = { type FreeIssueRow = {
@@ -65,14 +82,25 @@ export default function NewFreeIssuePage() {
const [cashierUserId, setCashierUserId] = useState<number | null>(null) const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")]) const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [rows, setRows] = useState<FreeIssueRow[]>([]) const [rows, setRows] = useState<FreeIssueRow[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [editingRowId, setEditingRowId] = useState<number | null>(null) const [editingRowId, setEditingRowId] = useState<number | null>(null)
const [editingLines, setEditingLines] = useState<Line[]>([]) const [editingLines, setEditingLines] = useState<Line[]>([])
const [formOpen, setFormOpen] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
async function refreshRows() { async function refreshRows() {
const list = await salesApi.listFreeIssues({ pageSize: 50 }) const list = await salesApi.listFreeIssues({ page, pageSize })
setPagination(list.pagination)
const details = await Promise.all( const details = await Promise.all(
list.items.map(async (summary) => { list.items.map(async (summary) => {
const detail = await salesApi.getFreeIssue(summary.salesSlipId) const detail = await salesApi.getFreeIssue(summary.salesSlipId)
@@ -126,6 +154,10 @@ export default function NewFreeIssuePage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
useEffect(() => {
if (!loading) refreshRows().finally(() => setShowMoreLoading(false))
}, [page, pageSize])
function updateLine(key: string, patch: Partial<Line>) { function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
} }
@@ -134,18 +166,6 @@ export default function NewFreeIssuePage() {
setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line))) setEditingLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
} }
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function clearAllLines() {
setLines([blankLine("line-1")])
}
const itemOptions = useMemo( const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })), () => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
[items] [items]
@@ -202,6 +222,7 @@ export default function NewFreeIssuePage() {
} }
setLines([blankLine("line-1")]) setLines([blankLine("line-1")])
setFormOpen(false)
await refreshRows() await refreshRows()
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
@@ -236,6 +257,7 @@ export default function NewFreeIssuePage() {
parentLineId: detailLine?.parentLineId ?? null, parentLineId: detailLine?.parentLineId ?? null,
}, },
]) ])
setFormOpen(true)
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
} }
@@ -246,12 +268,25 @@ export default function NewFreeIssuePage() {
setEditingLines([]) setEditingLines([])
} }
function openCreateForm() {
cancelEdit()
setError(null)
}
function handleFormOpenChange(open: boolean) {
setFormOpen(open)
if (!open) cancelEdit()
}
async function deleteRow(row: FreeIssueRow) { async function deleteRow(row: FreeIssueRow) {
if (row.status !== "Draft") return if (row.status !== "Draft") return
try { try {
await salesApi.cancelFreeIssue(row.salesSlipId) await salesApi.cancelFreeIssue(row.salesSlipId)
toast.success("Free issue cancelled", row.slipNo) toast.success("Free issue cancelled", row.slipNo)
if (editingRowId === row.salesSlipId) cancelEdit() if (editingRowId === row.salesSlipId) {
cancelEdit()
setFormOpen(false)
}
await refreshRows() await refreshRows()
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
@@ -276,197 +311,254 @@ export default function NewFreeIssuePage() {
<p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p> <p className="text-base text-muted-foreground">Create and manage free-issue sales slips from the backend.</p>
</div> </div>
</div> </div>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : editingRowId ? "Update free issue" : "Create free issue"}
</Button>
</div>
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null} <Dialog open={formOpen} onOpenChange={handleFormOpenChange}>
<DialogTrigger
{editingRowId ? ( render={
<section className="rounded-2xl border border-sky-200 bg-sky-50 shadow-[var(--shadow-panel)]"> <Button size="lg" onClick={openCreateForm}>
<div className="flex items-center justify-between border-b border-sky-200 px-4 py-3"> <Plus className="size-5" />
<h2 className="text-sm font-semibold text-sky-900">Inline edit free issue</h2> New Free Issue
<Button type="button" variant="outline" size="sm" onClick={cancelEdit}>
<X className="size-4" /> Cancel edit
</Button> </Button>
</div> }
<div className="overflow-x-auto"> />
<Table className="text-sm"> <DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<TableHeader> <DialogHeader className="items-center text-center">
<TableRow> <DialogTitle>{editingRowId ? "Edit free issue" : "New free issue"}</DialogTitle>
<TableHead className="h-12 px-4 text-sm">ID</TableHead> <DialogDescription>Pick a customer, warehouse and cashier, then add the item lines.</DialogDescription>
<TableHead className="h-12 px-4 text-sm">Item</TableHead> </DialogHeader>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead> {error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm" /> <FieldGroup>
</TableRow> <Field>
</TableHeader> <FieldLabel htmlFor="fi-customer">Customer</FieldLabel>
<TableBody> <Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
{activeLines.map((line, idx) => ( <SelectTrigger id="fi-customer" className="w-full"><SelectValue placeholder="Select customer" /></SelectTrigger>
<TableRow key={line.key} className="align-middle"> <SelectContent>
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell> {customers.map((c) => (
<TableCell className="px-4 py-2 min-w-80"> <SelectItem key={c.customerId} value={String(c.customerId)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-warehouse">Warehouse</FieldLabel>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
<SelectTrigger id="fi-warehouse" className="w-full"><SelectValue placeholder="Select warehouse" /></SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="fi-cashier">Cashier</FieldLabel>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger id="fi-cashier" className="w-full"><SelectValue placeholder="Select cashier" /></SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
{activeLines.map((line) => (
<FieldGroup key={line.key}>
<Field>
<FieldLabel>Item</FieldLabel>
<Combobox <Combobox
items={itemOptions} items={itemOptions}
value={line.itemId || null} value={line.itemId || null}
onValueChange={(v) => selectEditingItem(line.key, v ?? 0)} onValueChange={(v) => (editingRowId ? selectEditingItem(line.key, v ?? 0) : selectItem(line.key, v ?? 0))}
placeholder="Search item…" placeholder="Search item…"
/> />
</TableCell> </Field>
<TableCell className="px-4 py-2 min-w-44 text-sm"> <Field>
{baseUomLabel(items, uoms, line.itemId)} <FieldLabel>UOM</FieldLabel>
</TableCell> <Input value={baseUomLabel(items, uoms, line.itemId)} readOnly disabled className="text-muted-foreground" />
<TableCell className="px-4 py-2"> </Field>
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateEditingLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> <Field>
</TableCell> <FieldLabel htmlFor={`fi-qty-${line.key}`}>Qty</FieldLabel>
<TableCell className="px-4 py-2"> <Input
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> id={`fi-qty-${line.key}`}
</TableCell> type="number"
<TableCell className="px-4 py-2 text-right text-xs">editing</TableCell> min="0"
</TableRow> step="1"
))} value={line.qty}
</TableBody> onChange={(e) => (editingRowId ? updateEditingLine(line.key, { qty: Number(e.target.value) }) : updateLine(line.key, { qty: Number(e.target.value) }))}
</Table> className="font-mono tabular-nums"
</div>
</section>
) : (
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Free issue lines</h2>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={clearAllLines}
disabled={lines.length === 1 && !lines[0].itemId}
>
<Eraser className="size-4" /> Clear all lines
</Button>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
</div>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">ID</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…"
/> />
</TableCell> </Field>
<TableCell className="px-4 py-2 min-w-44"> <Field>
{baseUomLabel(items, uoms, line.itemId)} <FieldLabel htmlFor={`fi-free-${line.key}`}>Free</FieldLabel>
</TableCell> <Input
<TableCell className="px-4 py-2"> id={`fi-free-${line.key}`}
<Input type="number" min="0" step="1" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> type="number"
</TableCell> min="0"
<TableCell className="px-4 py-2"> step="1"
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" /> value={line.freeQty}
</TableCell> onChange={(e) => (editingRowId ? updateEditingLine(line.key, { freeQty: Number(e.target.value) }) : updateLine(line.key, { freeQty: Number(e.target.value) }))}
<TableCell className="px-4 py-2 text-right"> className="font-mono tabular-nums"
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => removeLine(line.key)} disabled={lines.length === 1}> />
<Trash2 className="size-4" /> </Field>
</Button> </FieldGroup>
</TableCell>
</TableRow>
))} ))}
</TableBody>
</Table> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => handleFormOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={submit} disabled={saving}>
<Save className="size-4" />
{saving ? "Saving…" : editingRowId ? "Update free issue" : "Create free issue"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
</section>
)}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]"> <section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 className="text-sm font-semibold">Created free issues</h3> <h3 className="text-sm font-semibold">Created free issues</h3>
<p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p> <p className="mt-1 text-xs text-muted-foreground">Persisted backend records with draft-only cancellation.</p>
</div> </div>
<Button variant="outline" size="sm" onClick={refreshRows}> <Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={refreshRows}>
Refresh Refresh
</Button> </Button>
</div> </div>
<div className="mt-3 overflow-x-auto">
<Table className="text-sm"> {rows.length > 0 ? (
<>
{/* Mobile: stacked cards. */}
<div className="mt-3 flex flex-col gap-3 md:hidden">
{rows.map((row) => (
<div key={row.salesSlipId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="min-w-0">
<p className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</p>
<p className="text-sm text-muted-foreground">{row.slipNo}</p>
</div>
<div className="border-t pt-3 text-sm">
<p className="font-medium text-foreground">{row.itemName}</p>
<p className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{row.warehouseName}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{row.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Free</p>
<p className="font-medium text-foreground">{row.freeQty}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
<Pencil className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => deleteRow(row)}
aria-label={`Delete ${row.slipNo}`}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</div>
))}
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="mt-3 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-4 text-sm">Promotion</TableHead> <TableHead className="w-48">Promotion</TableHead>
<TableHead className="h-12 px-4 text-sm">Warehouse</TableHead> <TableHead>Warehouse</TableHead>
<TableHead className="h-12 px-4 text-sm">Product</TableHead> <TableHead>Product</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead> <TableHead className="text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead> <TableHead className="text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead> <TableHead className="w-40 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.length > 0 ? ( {rows.map((row) => (
rows.map((row) => ( <TableRow key={row.salesSlipId} className="align-top hover:bg-muted/20">
<TableRow key={row.salesSlipId} className="hover:bg-muted/40"> <TableCell>
<TableCell className="px-4 py-3.5"> <div className="font-semibold text-foreground">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="font-medium">Buy {row.qty} Get {row.freeQty || 0}</div>
<div className="text-xs text-muted-foreground">{row.slipNo}</div> <div className="text-xs text-muted-foreground">{row.slipNo}</div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5">{row.warehouseName}</TableCell> <TableCell className="text-sm text-muted-foreground">{row.warehouseName}</TableCell>
<TableCell className="px-4 py-3.5 min-w-80"> <TableCell className="min-w-80 text-sm text-muted-foreground">
<div className="font-medium">{row.itemName}</div> <div className="font-medium text-foreground">{row.itemName}</div>
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div> <div className="text-xs">{row.itemSku} · {row.uomName}</div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.qty}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.qty}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.freeQty}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.freeQty}</TableCell>
<TableCell className="px-4 py-3.5"> <TableCell>
<div className="flex justify-end gap-1.5"> <div className="flex items-center justify-center gap-1">
<button <Button variant="ghost" size="icon-sm" onClick={() => startEdit(row)} aria-label={`Edit ${row.slipNo}`} title="Edit">
type="button" <Pencil className="size-4" />
onClick={() => startEdit(row)} </Button>
className="inline-flex h-7 items-center rounded-full border border-sky-200 bg-sky-50 px-2 text-xs text-sky-700 hover:bg-sky-100 hover:text-sky-800" {row.status === "Draft" && (
> <Button
<Pencil className="mr-1 size-3.5" /> variant="ghost"
Edit size="icon-sm"
</button> className="text-destructive hover:bg-destructive/10"
{row.status === "Draft" ? (
<button
type="button"
onClick={() => deleteRow(row)} onClick={() => deleteRow(row)}
className="inline-flex h-7 items-center rounded-full border border-rose-200 bg-rose-50 px-2 text-xs text-rose-700 hover:bg-rose-100 hover:text-rose-800" aria-label={`Delete ${row.slipNo}`}
title="Delete"
> >
<Trash2 className="mr-1 size-3.5" /> <Trash2 className="size-4" />
Delete </Button>
</button> )}
) : null}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))}
) : (
<TableRow>
<TableCell colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">
No free issues created yet.
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
{pagination && (
<div className="mt-3 hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
) : (
<div className="mt-3 flex flex-col items-center gap-2 rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
No free issues created yet.
</div>
)}
</section> </section>
</div> </div>
) )
@@ -603,7 +603,182 @@ function NewSalesInvoiceContent() {
</div> </div>
</div> </div>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{displayRows.map((row, idx) => {
if (row.type === "bundle") {
const bundleTotal = row.lines.reduce((sum, l) => sum + lineTotals(l).net, 0)
const bundleFreeQty = row.lines.reduce((sum, l) => sum + Number(l.freeQty || 0), 0)
const bundleQty = row.lines[0]?.bundleQty ?? 1
return (
<div key={row.groupId} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-foreground">Bundle: {row.bundleName}</p>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{row.lines.map((l) => (
<li key={l.key}>
{items.find((i) => i.itemId === l.itemId)?.name ?? `Item ${l.itemId}`} × {l.qty}
</li>
))}
</ul>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeBundleGroup(row.groupId)}
disabled={displayRows.length === 1}
aria-label="Remove bundle"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Bundles</Label>
<Input
type="number"
min="1"
step="1"
value={bundleQty}
onChange={(e) => updateBundleQty(row.groupId, Number(e.target.value))}
className="h-11 text-sm tabular-nums"
aria-label="Bundle quantity"
/>
</div>
<div>
<Label className="text-sm">Free qty</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">{bundleFreeQty || "—"}</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Total</span>
<span className="font-semibold tabular-nums">{lkr.format(bundleTotal)}</span>
</div>
</div>
)
}
const line = row.line
const { net: lineNet } = lineTotals(line)
const scheme = freeIssueSchemeFor(line.itemId)
return (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item {idx + 1}</Label>
<Combobox
items={lineComboOptions}
value={line.itemId ? `item:${line.itemId}` : null}
onValueChange={(v) => selectLineOption(line.key, v)}
placeholder="Search item or bundle…"
className="h-11 text-sm"
/>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-6 shrink-0"
onClick={() => removeLine(line.key)}
disabled={displayRows.length === 1}
aria-label="Remove line"
>
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">UOM</Label>
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</div>
</div>
<div>
<Label className="text-sm">Qty</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.qty}
onChange={(e) => updateQty(line.key, line.itemId, Number(e.target.value))}
className="h-11 text-sm tabular-nums"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Free</Label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
{scheme && (
<p className="mt-1 text-xs text-muted-foreground">
{scheme.schemeLabel || `Buy ${scheme.qty} Get ${scheme.freeQty}`}
</p>
)}
</div>
<div>
<Label className="text-sm">Disc %</Label>
<Input
type="number"
min="0"
max="100"
step="0.5"
inputMode="decimal"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-11 text-sm tabular-nums"
/>
</div>
</div>
<div>
<Label className="text-sm">Unit price</Label>
<Input
type="number"
min="0"
step="1"
inputMode="decimal"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-11 text-sm tabular-nums"
/>
<p className="mt-1 text-xs text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</p>
</div>
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Line total</span>
<span className="font-semibold tabular-nums">{lkr.format(lineNet)}</span>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm"> <Table className="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -773,31 +948,31 @@ function NewSalesInvoiceContent() {
</Table> </Table>
</div> </div>
<div className="flex flex-wrap justify-end gap-x-8 gap-y-1 border-t border-border pt-4 text-base"> <div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-border pt-4 text-sm sm:flex sm:flex-wrap sm:justify-end sm:gap-x-8 sm:gap-y-1 sm:text-base">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Gross</span> <span className="text-sm text-muted-foreground">Gross</span>
<span className="tabular-nums">{lkr.format(grossTotal)}</span> <span className="tabular-nums">{lkr.format(grossTotal)}</span>
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Discount</span> <span className="text-sm text-muted-foreground">Discount</span>
<span className="tabular-nums">-{lkr.format(discountTotal)}</span> <span className="tabular-nums">-{lkr.format(discountTotal)}</span>
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-sm text-muted-foreground">Tax</span> <span className="text-sm text-muted-foreground">Tax</span>
<span className="tabular-nums">{lkr.format(taxTotal)}</span> <span className="tabular-nums">{lkr.format(taxTotal)}</span>
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline justify-between gap-2 sm:justify-start">
<span className="text-muted-foreground">Payable</span> <span className="text-muted-foreground">Payable</span>
<span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span> <span className="font-semibold tabular-nums">{lkr.format(payableTotal)}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={submit} disabled={saving}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"} {saving ? "Creating…" : "Create Sale"}
</Button> </Button>
</div> </div>
@@ -31,6 +31,9 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All" type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 25
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 25
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
{ value: "All", label: "All" }, { value: "All", label: "All" },
{ value: "Draft", label: "Draft" }, { value: "Draft", label: "Draft" },
@@ -50,6 +53,8 @@ export default function SalesInvoicesPage() {
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [confirmingId, setConfirmingId] = useState<number | null>(null) const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null) const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false) const [payDialogOpen, setPayDialogOpen] = useState(false)
@@ -64,7 +69,10 @@ export default function SalesInvoicesPage() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [searchInput])
useEffect(() => setPage(1), [query, status, activeBranchId, customerId, warehouseId]) useEffect(() => {
setPage(1)
setPageSize(PAGE_SIZE)
}, [query, status, activeBranchId, customerId, warehouseId])
useEffect(() => { useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
@@ -76,7 +84,7 @@ export default function SalesInvoicesPage() {
salesApi salesApi
.listInvoices({ .listInvoices({
page, page,
pageSize: PAGE_SIZE, pageSize,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId, customerId,
@@ -87,7 +95,13 @@ export default function SalesInvoicesPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [page, status, activeBranchId, branchingEnabled, customerId, warehouseId]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, status, activeBranchId, branchingEnabled, customerId, warehouseId])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
async function handleConfirm(row: SalesInvoiceSummary) { async function handleConfirm(row: SalesInvoiceSummary) {
setConfirmingId(row.salesInvoiceId) setConfirmingId(row.salesInvoiceId)
@@ -137,17 +151,17 @@ export default function SalesInvoicesPage() {
<h1 className="text-2xl font-bold text-foreground">Sales</h1> <h1 className="text-2xl font-bold text-foreground">Sales</h1>
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p> <p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Link <Link
href={printHref} href={printHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}
> >
<Printer className="size-5" /> <Printer className="size-5" />
Print batch Print batch
</Link> </Link>
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Sale New Sale
</Link> </Link>
@@ -224,7 +238,112 @@ export default function SalesInvoicesPage() {
{!error && rows !== null && visibleRows.length > 0 && ( {!error && rows !== null && visibleRows.length > 0 && (
<> <>
<div className="overflow-x-auto"> {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="rounded-lg border bg-muted/20 px-2 py-2">
<div className="text-xs text-muted-foreground">Gross</div>
<div className="font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-2 py-2">
<div className="text-xs text-muted-foreground">Discount</div>
<div className="font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-2 py-2">
<div className="text-xs text-muted-foreground">Net</div>
<div className="font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
</div>
</div>
{visibleRows.map((row) => (
<div
key={row.salesInvoiceId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<SalesInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Date / Due</p>
<p className="font-medium text-foreground">
{new Date(row.createdAt).toLocaleDateString()} / {new Date(row.invoiceDate).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-muted-foreground">Lines</p>
<p className="font-medium text-foreground">{row.totals.freeQtyTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Gross / Discount</p>
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Net</p>
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
</div>
</div>
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
{row.status === "Draft" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmingId === row.salesInvoiceId}
onClick={() => handleConfirm(row)}
>
<CheckCircle2 className="size-4" />
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
)}
</div>
))}
{pagination && rows !== null && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -305,7 +424,7 @@ export default function SalesInvoicesPage() {
</Table> </Table>
</div> </div>
<div className="border-t px-4 py-3"> <div className="hidden border-t px-4 py-3 md:block">
<div className="grid gap-3 text-sm md:grid-cols-3"> <div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2"> <div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Gross total</div> <div className="text-muted-foreground">Gross total</div>
@@ -323,7 +442,7 @@ export default function SalesInvoicesPage() {
</div> </div>
{pagination && ( {pagination && (
<div className="border-t px-4 py-4"> <div className="hidden border-t px-4 py-4 md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} /> <PaginationFooter pagination={pagination} onPageChange={setPage} />
</div> </div>
)} )}
@@ -1,34 +1,10 @@
"use client" "use client"
import { Suspense, useEffect, useState } from "react" import { Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { salesReturnsApi } from "@/lib/api/sales-returns" import { SalesReturnForm } from "@/components/sales/SalesReturnForm"
import { salesApi } from "@/lib/api/sales"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateSalesReturnLine } from "@/lib/validations/sales"
import { cn } from "@/lib/utils"
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface LineState {
selected: boolean
qty: string
}
function NewSalesReturnContent() { function NewSalesReturnContent() {
const router = useRouter() const router = useRouter()
@@ -36,143 +12,6 @@ function NewSalesReturnContent() {
const presetInvoiceId = Number(searchParams.get("invoiceId")) || null const presetInvoiceId = Number(searchParams.get("invoiceId")) || null
const presetLineId = Number(searchParams.get("lineId")) || null const presetLineId = Number(searchParams.get("lineId")) || null
const [invoices, setInvoices] = useState<SalesInvoice[] | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
const [remainingLoading, setRemainingLoading] = useState(false)
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [lineState, setLineState] = useState<Record<number, LineState>>({})
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
.then(([invoiceList, it, rc]) => {
// Only Posted invoices have stock movements to return against.
Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data)))
setItems(it.items)
setReasonCodes(rc.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null
useEffect(() => {
if (!selectedInvoice) {
setLineState({})
setRemainingByLine({})
return
}
const next: Record<number, LineState> = {}
for (const line of selectedInvoice.lines) {
next[line.salesInvoiceLineId] = { selected: false, qty: "" }
}
setLineState(next)
setRemainingLoading(true)
salesReturnsApi
.getRemaining(selectedInvoice.salesInvoiceId)
.then((rows) => {
const map: Record<number, number> = {}
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
setRemainingByLine(map)
if (presetLineId) {
const remaining = map[presetLineId] ?? 0
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
}
})
.catch((err) => setLoadError(errorMessage(err)))
.finally(() => setRemainingLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInvoice?.salesInvoiceId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function remainingFor(lineId: number, fallback: number) {
return remainingByLine[lineId] ?? fallback
}
function toggleLine(line: SalesInvoiceLine) {
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
setLineState((prev) => ({
...prev,
[line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) },
}))
}
function setQty(lineId: number, qty: string) {
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
if (!selectedInvoice) {
setHeaderError("Select an invoice to return against.")
return
}
if (!reasonCodeId) {
setHeaderError("Select a reason code.")
return
}
const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected)
if (selectedLines.length === 0) {
setSubmitError("Select at least one line to return.")
return
}
const nextErrors: Record<number, Record<string, string>> = {}
for (const line of selectedLines) {
const errors = validateSalesReturnLine({
salesInvoiceLineId: line.salesInvoiceLineId,
qty: lineState[line.salesInvoiceLineId].qty,
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
})
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
}
setLineErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({
salesInvoiceLineId: l.salesInvoiceLineId,
itemId: l.itemId,
qty: Number(lineState[l.salesInvoiceLineId].qty),
}))
setSubmitting(true)
try {
const salesReturn = await salesReturnsApi.create({
customerId: selectedInvoice.customerId,
warehouseId: selectedInvoice.warehouseId,
reasonCodeId,
lines: payloadLines,
})
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
router.push("/dashboard/sales/sales-returns")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not post sales return", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !invoices
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -182,118 +21,12 @@ function NewSalesReturnContent() {
</div> </div>
</div> </div>
{loadError && ( <SalesReturnForm
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div> presetInvoiceId={presetInvoiceId}
)} presetLineId={presetLineId}
onCancel={() => router.push("/dashboard/sales/sales-returns")}
{loading && !loadError && <Skeleton className="h-24 w-full" />} onSuccess={() => router.push("/dashboard/sales/sales-returns")}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Sales Invoice</Label>
<Select<number | null> value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a posted invoice" />
</SelectTrigger>
<SelectContent>
{(invoices ?? []).map((inv) => (
<SelectItem key={inv.salesInvoiceId} value={inv.salesInvoiceId} className="text-base">
{inv.invoiceNo} — {inv.customerSnapshotName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Reason</Label>
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{reasonCodes.map((rc) => (
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
{rc.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
{selectedInvoice && (
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines invoiced on {selectedInvoice.invoiceNo}</h2>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Invoiced qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Remaining qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedInvoice.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
const fullyReturned = !remainingLoading && remaining <= 0
return (
<TableRow key={line.salesInvoiceLineId}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
{remainingLoading ? "…" : fullyReturned ? <span className="text-muted-foreground">Fully returned</span> : remaining}
</TableCell>
<TableCell className="px-3 py-3.5">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected || fullyReturned}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
className="h-11 text-base"
/> />
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/sales-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"}
</Button>
</div>
</>
)}
</div> </div>
) )
} }
@@ -9,32 +9,71 @@ import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes" import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { SalesReturnSummary } from "@/types/sales" import { SalesReturnSummary } from "@/types/sales"
import { Customer } from "@/types/customers" import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function SalesReturnsListPage() { export default function SalesReturnsListPage() {
const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null) const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null)
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([]) const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([salesReturnsApi.list(), customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
.then(([r, c, w, rc]) => { return () => clearTimeout(timeout)
setReturns(r.items) }, [searchInput])
useEffect(() => setPage(1), [query])
function load() {
setError(null)
salesReturnsApi
.list({ page, pageSize, q: query || undefined })
.then((res) => {
setReturns(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, query])
useEffect(() => {
Promise.all([customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([c, w, rc]) => {
setCustomers(c.items) setCustomers(c.items)
setWarehouses(w.items) setWarehouses(w.items)
setReasonCodes(rc.items) setReasonCodes(rc.items)
}) })
.catch((err) => setError(errorMessage(err))) .catch(() => {})
}, []) }, [])
function customerLabel(id: number) { function customerLabel(id: number) {
@@ -48,6 +87,8 @@ export default function SalesReturnsListPage() {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
} }
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -55,20 +96,42 @@ export default function SalesReturnsListPage() {
<h1 className="text-2xl font-bold text-foreground">Sales Returns</h1> <h1 className="text-2xl font-bold text-foreground">Sales Returns</h1>
<p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p> <p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p>
</div> </div>
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Return New Return
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., customer…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search sales returns"
/>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && returns === null && ( {!error && returns === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -76,47 +139,113 @@ export default function SalesReturnsListPage() {
{!error && returns !== null && returns.length === 0 && ( {!error && returns !== null && returns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Undo2 className="size-12 text-muted-foreground" /> <Undo2 className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No sales returns yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No sales returns match your search." : "No sales returns yet."}</p>
{!hasFilters && (
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" /> <Plus className="size-5" />
New Return New Return
</Link> </Link>
)}
</div> </div>
)} )}
{!error && returns !== null && returns.length > 0 && ( {!error && returns !== null && returns.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{returns.map((r) => (
<div key={r.returnId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{r.docNo}</p>
<p className="text-sm text-muted-foreground">{customerLabel(r.customerId)}</p>
</div>
<Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{warehouseCode(r.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Reason</p>
<p className="font-medium text-foreground">{reasonLabel(r.reasonCodeId)}</p>
</div>
<div>
<p className="text-muted-foreground">Return qty</p>
<p className="font-medium text-foreground">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</p>
</div>
<div>
<p className="text-muted-foreground">GL Journal</p>
<p className="font-medium text-foreground">{r.glJournalNo ?? "—"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))}
{pagination && returns.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="w-40">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Customer</TableHead> <TableHead className="w-44">Customer</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <TableHead className="w-32">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead> <TableHead className="w-36">Reason</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Return Qty</TableHead> <TableHead className="w-28 text-right">Return Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="w-28">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead> <TableHead className="w-32">GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead className="w-40">Created</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{returns.map((r) => ( {returns.map((r) => (
<TableRow key={r.returnId}> <TableRow key={r.returnId} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{customerLabel(r.customerId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{customerLabel(r.customerId)}</TableCell>
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success"> <Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell> <TableCell className="text-sm text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -86,7 +86,7 @@ export default function BranchConfigurationPage() {
{!error && !config && <Skeleton className="h-40 w-full" />} {!error && !config && <Skeleton className="h-40 w-full" />}
{!error && config && ( {!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6"> <div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="size-5 text-primary" /> <MapPin className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Branches</h2> <h2 className="text-lg font-semibold text-foreground">Branches</h2>
@@ -160,7 +160,7 @@ export default function CompanyProfilePage() {
{!error && !profile && <Skeleton className="h-96 w-full" />} {!error && !profile && <Skeleton className="h-96 w-full" />}
{!error && profile && ( {!error && profile && (
<div className="flex flex-col gap-6 rounded-xl border p-6"> <div className="flex flex-col gap-6 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Building2 className="size-5 text-primary" /> <Building2 className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Identity & contact</h2> <h2 className="text-lg font-semibold text-foreground">Identity & contact</h2>
@@ -272,14 +272,14 @@ export default function CompanyProfilePage() {
)} )}
</div> </div>
<div className="flex items-center justify-between border-t pt-4"> <div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
{profile.updatedAt && ( {profile.updatedAt && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Last changed {new Date(profile.updatedAt).toLocaleString()} Last changed {new Date(profile.updatedAt).toLocaleString()}
{profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}. {profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}.
</p> </p>
)} )}
<Button type="button" onClick={save} disabled={saving} className="ml-auto"> <Button type="button" onClick={save} disabled={saving} className="w-full sm:ml-auto sm:w-auto">
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </div>
@@ -106,7 +106,7 @@ export default function InvoiceTemplatePage() {
{!error && config && ( {!error && config && (
<> <>
<div className="flex flex-col gap-4 rounded-xl border p-6"> <div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LayoutTemplate className="size-5 text-primary" /> <LayoutTemplate className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Layout</h2> <h2 className="text-lg font-semibold text-foreground">Layout</h2>
@@ -172,20 +172,20 @@ export default function InvoiceTemplatePage() {
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex items-center justify-between pt-2"> <div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
{config.updatedAt && ( {config.updatedAt && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()} Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}. {config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p> </p>
)} )}
<Button type="button" onClick={save} disabled={saving} className="ml-auto"> <Button type="button" onClick={save} disabled={saving} className="w-full sm:ml-auto sm:w-auto">
{saving ? "Saving…" : "Save changes"} {saving ? "Saving…" : "Save changes"}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 rounded-xl border border-dashed p-6"> <div className="flex flex-col gap-3 rounded-xl border border-dashed p-4 sm:p-6">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Live preview</span> <span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Live preview</span>
{(() => { {(() => {
const layout = getInvoiceLayoutClasses(templateStyle) const layout = getInvoiceLayoutClasses(templateStyle)
@@ -199,7 +199,7 @@ export default function InvoiceTemplatePage() {
const companyBlock = <InvoicePrintHeader company={company} template={previewConfig} /> const companyBlock = <InvoicePrintHeader company={company} template={previewConfig} />
return ( return (
<div className={`rounded-2xl border bg-white ${layout.sheet}`}> <div className={`flex flex-col rounded-2xl border bg-white ${layout.sheet}`}>
{layout.arrangement === "compact" ? ( {layout.arrangement === "compact" ? (
<div className={`flex flex-wrap items-start justify-between ${layout.header}`}> <div className={`flex flex-wrap items-start justify-between ${layout.header}`}>
{titleBlock} {titleBlock}
@@ -225,7 +225,7 @@ export default function InvoiceTemplatePage() {
<div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>12,450.00</span></div> <div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>12,450.00</span></div>
</div> </div>
) : ( ) : (
<div className={`grid grid-cols-2 ${layout.infoGrid}`}> <div className={`grid grid-cols-1 sm:grid-cols-2 ${layout.infoGrid}`}>
<div className={layout.card}> <div className={layout.card}>
<div className={layout.cardLabel}>Customer</div> <div className={layout.cardLabel}>Customer</div>
<div className={layout.cardValue}>Sample Customer Ltd</div> <div className={layout.cardValue}>Sample Customer Ltd</div>
@@ -243,6 +243,7 @@ export default function InvoiceTemplatePage() {
)} )}
<div className={layout.tableWrap}> <div className={layout.tableWrap}>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className={layout.tableHeaderRow}> <thead className={layout.tableHeaderRow}>
<tr> <tr>
@@ -260,6 +261,7 @@ export default function InvoiceTemplatePage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div className="mt-4"> <div className="mt-4">
<InvoicePrintFooter company={company} template={previewConfig} /> <InvoicePrintFooter company={company} template={previewConfig} />
@@ -164,10 +164,9 @@ export default function RoleDetailPage() {
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="flex items-center justify-between"> <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>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1> <h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
<Badge <Badge
variant="outline" variant="outline"
@@ -181,11 +180,10 @@ export default function RoleDetailPage() {
</div> </div>
<p className="text-base text-muted-foreground">{role.name}</p> <p className="text-base text-muted-foreground">{role.name}</p>
</div> </div>
</div>
<Button <Button
variant="outline" variant="outline"
className={role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10"} className={cn("w-full sm:w-auto", role.status === "Active" ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10")}
onClick={handleToggleStatus} onClick={handleToggleStatus}
disabled={togglingStatus || role.isSystemRole} disabled={togglingStatus || role.isSystemRole}
title={role.isSystemRole ? "System roles can't be deactivated" : undefined} title={role.isSystemRole ? "System roles can't be deactivated" : undefined}
@@ -280,12 +278,12 @@ export default function RoleDetailPage() {
{!allActionsSet && ( {!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can save.</p> <p className="text-sm text-warning">Every selected section needs at least one action before you can save.</p>
)} )}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Back Back
</Button> </Button>
<Button onClick={handleSavePermissions} disabled={savingPermissions || !allActionsSet}> <Button className="w-full sm:w-auto" onClick={handleSavePermissions} disabled={savingPermissions || !allActionsSet}>
<Save className="size-4" /> <Save className="size-4" />
{savingPermissions ? "Saving…" : "Save permissions"} {savingPermissions ? "Saving…" : "Save permissions"}
</Button> </Button>
@@ -179,16 +179,16 @@ export default function NewRolePage() {
{!allActionsSet && ( {!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can create the role.</p> <p className="text-sm text-warning">Every selected section needs at least one action before you can create the role.</p>
)} )}
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Back Back
</Button> </Button>
<div className="flex gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}> <Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button onClick={handleCreate} disabled={submitting || !allActionsSet}> <Button className="w-full sm:w-auto" onClick={handleCreate} disabled={submitting || !allActionsSet}>
<Save className="size-4" /> <Save className="size-4" />
{submitting ? "Creating…" : "Create role"} {submitting ? "Creating…" : "Create role"}
</Button> </Button>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react" import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles" import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -30,6 +30,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE = 10 const PAGE_SIZE = 10
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function RolesPage() { export default function RolesPage() {
const router = useRouter() const router = useRouter()
@@ -37,21 +40,29 @@ export default function RolesPage() {
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() { function load() {
rolesApi rolesApi
.list({ page, pageSize: PAGE_SIZE, q: debouncedSearch || undefined }) .list({ page, pageSize, q: debouncedSearch || undefined })
.then((res) => { .then((res) => {
setRoles(res.items) setRoles(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [page, debouncedSearch]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [page, pageSize, debouncedSearch])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
@@ -170,7 +181,98 @@ export default function RolesPage() {
)} )}
{!error && roles !== null && roles.length > 0 && ( {!error && roles !== null && roles.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{roles.map((r) => (
<div
key={r.roleId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/roles/${r.roleId}`)
}}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/settings/roles/${r.roleId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-white text-foreground">
<ShieldCheck className="size-4.5" />
</div>
<div className="flex flex-col gap-1 pt-0.5">
<span className="truncate font-semibold leading-tight text-foreground">{r.name}</span>
<span className="font-mono text-xs text-muted-foreground">{r.code}</span>
</div>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{r.status}
</Badge>
</div>
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</span>
{r.isSystemRole && (
<Badge variant="outline" className="h-5 w-fit justify-center border-input bg-white px-2 text-xs text-muted-foreground">
System
</Badge>
)}
</div>
<div className="flex items-center gap-1" data-no-row-nav>
<Link
href={`/dashboard/settings/roles/${r.roleId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${r.code}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${r.code}`}
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
disabled={r.isSystemRole || actionPendingId === r.roleId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div>
</div>
</div>
))}
{pagination && roles.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -276,42 +378,15 @@ export default function RolesPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<p className="text-sm text-muted-foreground"> <PaginationFooter
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– pagination={pagination}
<span className="font-medium text-foreground"> onPageChange={setPage}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} />
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> roles
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -95,9 +95,9 @@ export default function UserDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1> <h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
<Badge <Badge
variant="outline" variant="outline"
@@ -197,8 +197,8 @@ export default function UserDetailPage() {
{!activityError && activity !== null && activity.length > 0 && ( {!activityError && activity !== null && activity.length > 0 && (
<ul className="flex flex-col divide-y divide-border"> <ul className="flex flex-col divide-y divide-border">
{activity.map((a) => ( {activity.map((a) => (
<li key={a.auditId} className="flex items-center justify-between gap-3 py-3"> <li key={a.auditId} className="flex flex-wrap items-center justify-between gap-2 py-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@@ -251,8 +251,8 @@ export default function UserDetailPage() {
{!devicesError && devices !== null && devices.length > 0 && ( {!devicesError && devices !== null && devices.length > 0 && (
<ul className="flex flex-col divide-y divide-border"> <ul className="flex flex-col divide-y divide-border">
{devices.map((d) => ( {devices.map((d) => (
<li key={d.loginActivityId} className="flex items-center justify-between gap-3 py-3"> <li key={d.loginActivityId} className="flex flex-wrap items-center justify-between gap-2 py-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Laptop className="size-4 shrink-0 text-muted-foreground" /> <Laptop className="size-4 shrink-0 text-muted-foreground" />
<span className="text-foreground">{d.deviceLabel ?? "Unknown device"}</span> <span className="text-foreground">{d.deviceLabel ?? "Unknown device"}</span>
{d.ipAddress && <span className="font-mono text-xs text-muted-foreground">{d.ipAddress}</span>} {d.ipAddress && <span className="font-mono text-xs text-muted-foreground">{d.ipAddress}</span>}
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react" import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -31,6 +31,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function UsersPage() { export default function UsersPage() {
const router = useRouter() const router = useRouter()
@@ -43,6 +46,7 @@ export default function UsersPage() {
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() { function load() {
usersApi usersApi
@@ -52,6 +56,12 @@ export default function UsersPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) useEffect(load, [page, pageSize, debouncedSearch])
@@ -190,7 +200,86 @@ export default function UsersPage() {
)} )}
{!error && users !== null && users.length > 0 && ( {!error && users !== null && users.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{users.map((u) => (
<div
key={u.userId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/settings/users/${u.userId}`)
}}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/dashboard/settings/users/${u.userId}`) }}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{u.username}</p>
<p className="text-sm text-muted-foreground">{u.displayName}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
u.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{u.status}
</Badge>
</div>
<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" }))}
aria-label={`Edit ${u.username}`}
title={`Edit ${u.username}`}
>
<Pencil className="size-4" />
</Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
u.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
title={u.status === "Active" ? `Deactivate ${u.username}` : `Activate ${u.username}`}
disabled={actionPendingId === u.userId}
onClick={() => handleToggleStatus(u)}
>
{u.status === "Active" ? <UserX className="size-4" /> : <UserCheck className="size-4" />}
</Button>
</div>
</div>
</div>
))}
{pagination && users.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -235,6 +324,14 @@ export default function UsersPage() {
</TableCell> </TableCell>
<TableCell className="px-3 py-4" data-no-row-nav> <TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -267,57 +364,17 @@ export default function UsersPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> users
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -244,7 +244,7 @@ export default function NewAdjustmentPage() {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}> <Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" /> <Plus className="size-5" />
@@ -252,6 +252,51 @@ export default function NewAdjustmentPage() {
</Button> </Button>
</div> </div>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item</Label>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</div>
<div>
<Label className="text-sm">Qty change</Label>
<Input
type="number"
step="any"
inputMode="decimal"
placeholder="e.g. -15 or 50"
value={line.qtyDelta}
aria-invalid={!!lineErrors[line.key]}
onChange={(e) => updateLine(line.key, { qtyDelta: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -299,16 +344,17 @@ export default function NewAdjustmentPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
{submitError && ( {submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Adjustment"} {submitting ? "Posting…" : "Post Adjustment"}
</Button> </Button>
</div> </div>
@@ -2,45 +2,80 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { Plus, SlidersHorizontal } from "lucide-react" import { Plus, Search, SlidersHorizontal } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments" import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes" import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockAdjustmentSummary } from "@/types/stock" import { StockAdjustmentSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AdjustmentStatusBadge } from "@/components/stock/status-badges" import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function AdjustmentsListPage() { export default function AdjustmentsListPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null) const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map()) const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map())
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([ const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
stockAdjustmentsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), return () => clearTimeout(timeout)
warehousesApi.list(), }, [searchInput])
reasonCodesApi.list("Adjustment"),
]) useEffect(() => setPage(1), [query])
.then(([a, wh, rc]) => {
setAdjustments(a.items) useEffect(() => {
setError(null)
stockAdjustmentsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => {
setAdjustments(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(() => {
Promise.all([warehousesApi.list(), reasonCodesApi.list("Adjustment")])
.then(([wh, rc]) => {
setWarehouses(wh.items) setWarehouses(wh.items)
setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description]))) setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description])))
}) })
.catch((err) => setError(errorMessage(err))) .catch(() => {})
}, [activeBranchId, branchingEnabled]) }, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -51,20 +86,45 @@ export default function AdjustmentsListPage() {
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p> <p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Adjustment New Adjustment
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 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-12 w-full pl-11 text-base"
aria-label="Search stock adjustments"
/>
</div>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && adjustments === null && ( {!error && adjustments === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -72,41 +132,98 @@ export default function AdjustmentsListPage() {
{!error && adjustments !== null && adjustments.length === 0 && ( {!error && adjustments !== null && adjustments.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SlidersHorizontal className="size-12 text-muted-foreground" /> <SlidersHorizontal className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No adjustments yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No adjustments match your search." : "No adjustments yet."}</p>
{!hasFilters && (
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" /> <Plus className="size-5" />
New Adjustment New Adjustment
</Link> </Link>
)}
</div> </div>
)} )}
{!error && adjustments !== null && adjustments.length > 0 && ( {!error && adjustments !== null && adjustments.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{adjustments.map((a) => (
<div key={a.adjustmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{a.docNo}</p>
<AdjustmentStatusBadge status={a.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{whCode(a.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Reason</p>
<p className="font-medium text-foreground">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</p>
</div>
<div>
<p className="text-muted-foreground">GL Journal</p>
<p className="font-medium text-foreground">{a.glJournalNo ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(a.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))}
{pagination && adjustments.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="w-40">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <TableHead className="w-32">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead> <TableHead className="w-36">Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="w-28">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead> <TableHead className="w-32">GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead className="w-40">Created</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{adjustments.map((a) => ( {adjustments.map((a) => (
<TableRow key={a.adjustmentId}> <TableRow key={a.adjustmentId} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5 font-medium">{a.docNo}</TableCell> <TableCell className="font-semibold text-foreground">{a.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{whCode(a.warehouseId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{whCode(a.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell> <TableCell className="text-sm text-muted-foreground">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<AdjustmentStatusBadge status={a.status} /> <AdjustmentStatusBadge status={a.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{a.glJournalNo ?? "—"}</TableCell> <TableCell className="text-sm text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -139,14 +139,15 @@ export default function CountDetailPage() {
</div> </div>
{count.status !== "Posted" && ( {count.status !== "Posted" && (
<div className="flex flex-col items-end gap-1.5"> <div className="flex flex-col items-stretch gap-1.5 sm:items-end">
<div className="flex gap-3"> <div className="flex flex-col gap-3 sm:flex-row">
<Button size="lg" variant="outline" onClick={handleSave} disabled={saving}> <Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={handleSave} disabled={saving}>
<Save className="size-5" /> <Save className="size-5" />
{saving ? "Saving…" : "Save counts"} {saving ? "Saving…" : "Save counts"}
</Button> </Button>
<Button <Button
size="lg" size="lg"
className="w-full sm:w-auto"
onClick={handlePost} onClick={handlePost}
disabled={posting || count.status !== "Counted"} disabled={posting || count.status !== "Counted"}
title={count.status !== "Counted" ? "Save counts first" : undefined} title={count.status !== "Counted" ? "Save counts first" : undefined}
@@ -191,6 +192,51 @@ export default function CountDetailPage() {
</div> </div>
)} )}
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{count.lines.map((line) => (
<div key={line.countLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-foreground">Item #{line.itemId}</p>
<p className="text-sm text-muted-foreground">{line.batchNo ?? line.serialNo ?? "—"}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">System qty</p>
<p className="font-medium text-foreground">{line.systemQty}</p>
</div>
<div>
<p className="text-muted-foreground">Counted qty</p>
{count.status !== "Posted" ? (
<Input
ref={(el) => {
qtyInputRefs.current[line.countLineId] = el
}}
type="number"
step="any"
inputMode="decimal"
value={drafts[line.countLineId] ?? ""}
onChange={(e) => setDrafts((prev) => ({ ...prev, [line.countLineId]: e.target.value }))}
className="h-11 text-base"
/>
) : (
<p className="font-medium text-foreground">{line.countedQty ?? "—"}</p>
)}
</div>
<div>
<p className="text-muted-foreground">Variance</p>
<p className={cn("font-medium", line.variance && line.variance !== 0 && (line.variance > 0 ? "text-success" : "text-destructive"))}>
{line.variance ?? "—"}
</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -233,5 +279,6 @@ export default function CountDetailPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
) )
} }
@@ -194,11 +194,11 @@ export default function NewCountPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Count"} {submitting ? "Creating…" : "Create Count"}
</Button> </Button>
</div> </div>
@@ -2,44 +2,78 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ClipboardList, Plus } from "lucide-react" import { useRouter } from "next/navigation"
import { ClipboardList, Eye, Plus, Search } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts" import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockCountSummary } from "@/types/stock" import { StockCountSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CountStatusBadge } from "@/components/stock/status-badges" import { CountStatusBadge } from "@/components/stock/status-badges"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function CountsListPage() { export default function CountsListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats() const stats = useDashboardStats()
const [counts, setCounts] = useState<StockCountSummary[] | null>(null) const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([ const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
stockCountsApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), return () => clearTimeout(timeout)
warehousesApi.list(), }, [searchInput])
])
.then(([c, wh]) => { useEffect(() => setPage(1), [query])
setCounts(c.items)
setWarehouses(wh.items) useEffect(() => {
setError(null)
stockCountsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => {
setCounts(res.items)
setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [activeBranchId, branchingEnabled]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(() => {
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([]))
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -50,7 +84,7 @@ export default function CountsListPage() {
<p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p> <p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Count New Count
</Link> </Link>
@@ -58,14 +92,39 @@ export default function CountsListPage() {
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} /> <KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 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-12 w-full pl-11 text-base"
aria-label="Search stock counts"
/>
</div>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && counts === null && ( {!error && counts === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -73,43 +132,128 @@ export default function CountsListPage() {
{!error && counts !== null && counts.length === 0 && ( {!error && counts !== null && counts.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ClipboardList className="size-12 text-muted-foreground" /> <ClipboardList className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No counts yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No counts match your search." : "No counts yet."}</p>
{!hasFilters && (
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" /> <Plus className="size-5" />
New Count New Count
</Link> </Link>
)}
</div> </div>
)} )}
{!error && counts !== null && counts.length > 0 && ( {!error && counts !== null && counts.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{counts.map((c) => (
<div
key={c.countId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/stock/counts/${c.countId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/stock/counts/${c.countId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{c.docNo}</p>
<CountStatusBadge status={c.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{whCode(c.warehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">Type</p>
<p className="font-medium text-foreground">{c.countType}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))}
{pagination && counts.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="w-40">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <TableHead className="w-32">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="w-32">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="w-28">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead className="w-40">Created</TableHead>
<TableHead className="w-20 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{counts.map((c) => ( {counts.map((c) => (
<TableRow key={c.countId}> <TableRow
<TableCell className="px-3 py-3.5"> key={c.countId}
<Link href={`/dashboard/stock/counts/${c.countId}`} className="font-medium text-primary hover:underline"> className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/stock/counts/${c.countId}`)
}}
>
<TableCell>
<Link href={`/dashboard/stock/counts/${c.countId}`} className="font-semibold text-foreground hover:underline">
{c.docNo} {c.docNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{whCode(c.warehouseId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{whCode(c.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{c.countType}</TableCell> <TableCell className="text-sm text-muted-foreground">{c.countType}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<CountStatusBadge status={c.status} /> <CountStatusBadge status={c.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(c.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/stock/counts/${c.countId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${c.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -2,21 +2,29 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { PackageSearch, Search } from "lucide-react" import { Eye, PackageSearch, Search } from "lucide-react"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { OnHand } from "@/types/stock" import { OnHand } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size, and
// since these rows are already loaded client-side, more just slice in immediately.
const MOBILE_PAGE_SIZE_STEP = 10
export default function StockEnquiryPage() { export default function StockEnquiryPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
@@ -27,6 +35,8 @@ export default function StockEnquiryPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [warehouseId, setWarehouseId] = useState<number | "All">("All") const [warehouseId, setWarehouseId] = useState<number | "All">("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
useEffect(() => { useEffect(() => {
Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
@@ -56,6 +66,18 @@ export default function StockEnquiryPage() {
.sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId) .sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId)
}, [rows, search, warehouseId, itemsById, warehousesById, activeBranchId, branchingEnabled]) }, [rows, search, warehouseId, itemsById, warehousesById, activeBranchId, branchingEnabled])
useEffect(() => setPage(1), [search, warehouseId])
const pagination = filtered
? {
page,
pageSize,
totalItems: filtered.length,
totalPages: Math.max(1, Math.ceil(filtered.length / pageSize)),
}
: null
const pagedRows = filtered?.slice((page - 1) * pageSize, page * pageSize) ?? null
const loading = rows === null || items === null || warehouses === null const loading = rows === null || items === null || warehouses === null
return ( return (
@@ -100,9 +122,21 @@ export default function StockEnquiryPage() {
)} )}
{loading && !error && ( {loading && !error && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -114,49 +148,126 @@ export default function StockEnquiryPage() {
</div> </div>
)} )}
{!loading && !error && filtered !== null && filtered.length > 0 && ( {!loading && !error && pagedRows !== null && pagedRows.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards, "Show more" instead of pagination. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Item</TableHead> {filtered!.slice(0, page * pageSize).map((row) => {
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">On hand</TableHead>
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
<TableHead className="h-12 px-3 text-sm">On hold</TableHead>
<TableHead className="h-12 px-3 text-sm">In transit</TableHead>
<TableHead className="h-12 px-3 text-sm">Reserved</TableHead>
<TableHead className="h-12 px-3 text-sm">Valuation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((row) => {
const item = itemsById.get(row.itemId) const item = itemsById.get(row.itemId)
const wh = warehousesById.get(row.warehouseId) const wh = warehousesById.get(row.warehouseId)
return ( return (
<TableRow key={`${row.itemId}-${row.warehouseId}`}> <Link
<TableCell className="px-3 py-3.5"> key={`${row.itemId}-${row.warehouseId}`}
<div className="font-medium">{item?.sku ?? `#${row.itemId}`}</div> href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
<div className="text-sm text-muted-foreground">{item?.name}</div> className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
</TableCell> >
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell> <p className="truncate font-semibold text-foreground">{item?.sku ?? `#${row.itemId}`}</p>
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell> <p className="text-sm text-muted-foreground">{item?.name}</p>
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell> <p className="text-sm text-muted-foreground">{wh?.code ?? `#${row.warehouseId}`}</p>
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell> </div>
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
<TableCell className="px-3 py-3.5"> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">On hand</p>
<p className="font-medium text-foreground">{row.onHand}</p>
</div>
<div>
<p className="text-muted-foreground">Available</p>
<p className="font-semibold text-foreground">{row.available}</p>
</div>
<div>
<p className="text-muted-foreground">On hold</p>
<p className="font-medium text-foreground">{row.onHold}</p>
</div>
<div>
<p className="text-muted-foreground">In transit</p>
<p className="font-medium text-foreground">{row.inTransit}</p>
</div>
<div>
<p className="text-muted-foreground">Reserved</p>
<p className="font-medium text-foreground">{row.reserved}</p>
</div>
</div>
</Link>
)
})}
{filtered && page * pageSize < filtered.length && (
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => setPage((p) => p + 1)}
>
Show more
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-28">SKU</TableHead>
<TableHead className="w-48">Item name</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="text-right">On hand</TableHead>
<TableHead className="text-right">Available</TableHead>
<TableHead className="text-right">On hold</TableHead>
<TableHead className="text-right">In transit</TableHead>
<TableHead className="text-right">Reserved</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedRows.map((row) => {
const item = itemsById.get(row.itemId)
const wh = warehousesById.get(row.warehouseId)
return (
<TableRow key={`${row.itemId}-${row.warehouseId}`} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${row.itemId}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item?.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.onHand}</TableCell>
<TableCell className="text-right font-semibold text-foreground tabular-nums">{row.available}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.onHold}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.inTransit}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{row.reserved}</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Link <Link
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`} href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
className="font-medium text-primary hover:underline" className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View valuation for ${item?.sku ?? row.itemId}`}
title="View"
> >
View <Eye className="size-4" />
</Link> </Link>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) )
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -13,6 +13,7 @@ import { PaginationMeta } from "@/types/common"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
@@ -21,7 +22,10 @@ import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE = 25 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function StockLedgerPage() { export default function StockLedgerPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
@@ -37,6 +41,8 @@ export default function StockLedgerPage() {
const [from, setFrom] = useState("") const [from, setFrom] = useState("")
const [to, setTo] = useState("") const [to, setTo] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
@@ -61,7 +67,7 @@ export default function StockLedgerPage() {
from: from || undefined, from: from || undefined,
to: to || undefined, to: to || undefined,
page, page,
pageSize: PAGE_SIZE, pageSize,
}) })
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
@@ -71,10 +77,18 @@ export default function StockLedgerPage() {
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
}) })
.finally(() => {
if (!cancelled) setShowMoreLoading(false)
})
return () => { return () => {
cancelled = true cancelled = true
} }
}, [itemId, warehouseId, from, to, page, activeBranchId, branchingEnabled]) }, [itemId, warehouseId, from, to, page, pageSize, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items]) const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
@@ -141,9 +155,21 @@ export default function StockLedgerPage() {
</div> </div>
{loading && !error && ( {loading && !error && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -157,18 +183,79 @@ export default function StockLedgerPage() {
{!loading && !error && entries !== null && entries.length > 0 && ( {!loading && !error && entries !== null && entries.length > 0 && (
<> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{entries.map((entry) => {
const item = itemsById.get(entry.itemId)
const wh = warehousesById.get(entry.warehouseId)
return (
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</p>
<p className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-20 shrink-0 justify-center text-sm",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty / Unit cost</p>
<p className="font-medium text-foreground">{entry.qtyBase} @ {entry.unitCost.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Value</p>
<p className="font-medium text-foreground">{entry.value.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Running balance</p>
<p className="font-semibold text-foreground">{entry.runningBalance}</p>
</div>
<div>
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">{entry.sourceDocType} #{entry.sourceDocId}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleString()}</p>
</div>
</div>
</div>
)
})}
{pagination && entries.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Date</TableHead> <TableHead className="w-40">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead> <TableHead className="w-32">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead> <TableHead className="w-32">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Direction</TableHead> <TableHead className="w-28">Direction</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead> <TableHead className="w-24 text-right">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead> <TableHead className="w-28 text-right">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Value</TableHead> <TableHead className="w-28 text-right">Value</TableHead>
<TableHead className="h-12 px-3 text-sm">Running balance</TableHead> <TableHead className="w-36 text-right">Running balance</TableHead>
<TableHead className="h-12 px-3 text-sm">Source</TableHead> <TableHead className="w-36">Source</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -176,11 +263,11 @@ export default function StockLedgerPage() {
const item = itemsById.get(entry.itemId) const item = itemsById.get(entry.itemId)
const wh = warehousesById.get(entry.warehouseId) const wh = warehousesById.get(entry.warehouseId)
return ( return (
<TableRow key={entry.ledgerId}> <TableRow key={entry.ledgerId} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5">{new Date(entry.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">{item?.sku ?? `#${entry.itemId}`}</TableCell> <TableCell className="font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${entry.warehouseId}`}</TableCell> <TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@@ -193,11 +280,11 @@ export default function StockLedgerPage() {
{entry.direction} {entry.direction}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{entry.qtyBase}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.qtyBase}</TableCell>
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.value.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell> <TableCell className="text-right font-semibold text-foreground tabular-nums">{entry.runningBalance}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="text-sm text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId} {entry.sourceDocType} #{entry.sourceDocId}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -205,8 +292,19 @@ export default function StockLedgerPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -95,6 +95,58 @@ export default function ReorderAlertsPage() {
)} )}
{!loading && !error && alerts.length > 0 && ( {!loading && !error && alerts.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{alerts.map((alert) => {
const key = `${alert.itemId}-${alert.warehouseId}`
const item = itemsById.get(alert.itemId)
const wh = warehousesById.get(alert.warehouseId)
const done = requested.has(key)
return (
<div key={key} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" />
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{item?.sku ?? `#${alert.itemId}`}</p>
<p className="text-sm text-muted-foreground">{item?.name}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Available</p>
<p className="font-medium text-destructive">{alert.available}</p>
</div>
<div>
<p className="text-muted-foreground">Reorder pt</p>
<p className="font-medium text-foreground">{alert.reorderPoint}</p>
</div>
<div>
<p className="text-muted-foreground">Suggested</p>
<p className="font-medium text-foreground">{alert.suggestedRequisitionQty}</p>
</div>
</div>
<p className="text-sm text-muted-foreground">{wh?.code ?? `#${alert.warehouseId}`}</p>
<Button
type="button"
size="sm"
variant={done ? "outline" : "default"}
className="w-full"
disabled={done || requesting === key}
onClick={() => handleRequisition(alert)}
>
{done ? "Requisitioned" : requesting === key ? "Creating…" : "Create requisition"}
</Button>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -143,6 +195,8 @@ export default function ReorderAlertsPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</>
)} )}
</div> </div>
) )
@@ -5,8 +5,10 @@ import { useParams } from "next/navigation"
import { CheckCircle2, PackageCheck, Truck } from "lucide-react" import { CheckCircle2, PackageCheck, Truck } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock" import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
import { ItemListItem } from "@/types/master-data"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -19,6 +21,7 @@ export default function TransferDetailPage() {
const transferId = Number(params.id) const transferId = Number(params.id)
const [transfer, setTransfer] = useState<StockTransfer | null>(null) const [transfer, setTransfer] = useState<StockTransfer | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [dispatching, setDispatching] = useState(false) const [dispatching, setDispatching] = useState(false)
const [receiving, setReceiving] = useState(false) const [receiving, setReceiving] = useState(false)
@@ -30,6 +33,15 @@ export default function TransferDetailPage() {
stockTransfersApi.get(transferId).then(setTransfer).catch((err) => setError(errorMessage(err))) stockTransfersApi.get(transferId).then(setTransfer).catch((err) => setError(errorMessage(err)))
}, [transferId]) }, [transferId])
useEffect(() => {
itemsApi.list({ pageSize: 200 }).then((res) => setItems(res.items)).catch(() => {})
}, [])
function itemName(itemId: number) {
const item = items.find((i) => i.itemId === itemId)
return item ? `${item.sku} — ${item.name}` : `#${itemId}`
}
async function handleDispatch() { async function handleDispatch() {
setError(null) setError(null)
setDispatching(true) setDispatching(true)
@@ -94,15 +106,15 @@ export default function TransferDetailPage() {
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex flex-col gap-3 sm:flex-row">
{transfer.status === "Draft" && ( {transfer.status === "Draft" && (
<Button size="lg" onClick={handleDispatch} disabled={dispatching}> <Button size="lg" className="w-full sm:w-auto" onClick={handleDispatch} disabled={dispatching}>
<Truck className="size-5" /> <Truck className="size-5" />
{dispatching ? "Dispatching…" : "Dispatch"} {dispatching ? "Dispatching…" : "Dispatch"}
</Button> </Button>
)} )}
{transfer.status === "InTransit" && ( {transfer.status === "InTransit" && (
<Button size="lg" onClick={handleReceive} disabled={receiving}> <Button size="lg" className="w-full sm:w-auto" onClick={handleReceive} disabled={receiving}>
<PackageCheck className="size-5" /> <PackageCheck className="size-5" />
{receiving ? "Receiving…" : "Receive"} {receiving ? "Receiving…" : "Receive"}
</Button> </Button>
@@ -140,26 +152,54 @@ export default function TransferDetailPage() {
</div> </div>
)} )}
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{transfer.lines.map((line) => (
<div key={line.transferLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<p className="font-semibold text-foreground">{itemName(line.itemId)}</p>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Src bin</p>
<p className="font-medium text-foreground">{line.srcBinId ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Dest bin</p>
<p className="font-medium text-foreground">{line.destBinId ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{line.qty}</p>
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Item</TableHead> <TableHead className="w-56">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Src bin</TableHead> <TableHead className="w-28">Src bin</TableHead>
<TableHead className="h-12 px-3 text-sm">Dest bin</TableHead> <TableHead className="w-28">Dest bin</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead> <TableHead className="w-24 text-right">Qty</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{transfer.lines.map((line) => ( {transfer.lines.map((line) => (
<TableRow key={line.transferLineId}> <TableRow key={line.transferLineId} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell> <TableCell className="py-5 font-semibold text-foreground">{itemName(line.itemId)}</TableCell>
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell> <TableCell className="py-5 text-sm text-muted-foreground">{line.srcBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell> <TableCell className="py-5 text-sm text-muted-foreground">{line.destBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell> <TableCell className="py-5 text-right text-sm text-muted-foreground tabular-nums">{line.qty}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
</div>
) )
} }
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { Plus, Trash2 } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
@@ -8,10 +8,12 @@ import { Plus, Trash2 } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CreateTransferLineInput } from "@/types/stock" import { CreateTransferLineInput } from "@/types/stock"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data" import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -44,10 +46,12 @@ export default function NewTransferPage() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [srcBins, setSrcBins] = useState<Bin[]>([]) const [srcBins, setSrcBins] = useState<Bin[]>([])
const [destBins, setDestBins] = useState<Bin[]>([]) const [destBins, setDestBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [branchId, setBranchId] = useState<number | "All">("All")
const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null) const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null)
const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null) const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null)
const [srcBinId, setSrcBinId] = useState<number | null>(null) const [srcBinId, setSrcBinId] = useState<number | null>(null)
@@ -66,6 +70,26 @@ export default function NewTransferPage() {
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => setLoadError(errorMessage(err)))
}, []) }, [])
useEffect(() => {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [])
const visibleWarehouses = useMemo(() => {
const all = warehouses ?? []
if (branchId === "All") return all
const matches = all.filter((w) => w.branchId === branchId)
// Fail open: if no warehouse is actually tagged with this branch (branch scoping isn't
// wired up for warehouses in every tenant), don't leave the picker with nothing to select.
return matches.length > 0 ? matches : all
}, [warehouses, branchId])
// Switching the branch filter can strand a selected warehouse outside the new list.
useEffect(() => {
if (srcWarehouseId && !visibleWarehouses.some((w) => w.warehouseId === srcWarehouseId)) setSrcWarehouseId(null)
if (destWarehouseId && !visibleWarehouses.some((w) => w.warehouseId === destWarehouseId)) setDestWarehouseId(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleWarehouses])
useEffect(() => { useEffect(() => {
if (!srcWarehouseId) { if (!srcWarehouseId) {
setSrcBins([]) setSrcBins([])
@@ -93,6 +117,10 @@ export default function NewTransferPage() {
async function handleSubmit() { async function handleSubmit() {
setSubmitError(null) setSubmitError(null)
if (branchId === "All") {
setSubmitError("Select a branch for this transfer.")
return
}
if (!srcWarehouseId || !destWarehouseId) { if (!srcWarehouseId || !destWarehouseId) {
setSubmitError("Select both a source and destination warehouse.") setSubmitError("Select both a source and destination warehouse.")
return return
@@ -122,7 +150,7 @@ export default function NewTransferPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, lines: payloadLines }) const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, branchId, lines: payloadLines })
toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`) toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`)
router.push(`/dashboard/stock/transfers/${transfer.transferId}`) router.push(`/dashboard/stock/transfers/${transfer.transferId}`)
} catch (err) { } catch (err) {
@@ -152,6 +180,27 @@ export default function NewTransferPage() {
{!loading && ( {!loading && (
<> <>
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label className="text-base">Branch</Label>
<Select<number | "All">
value={branchId}
onValueChange={(v) => setBranchId(v ?? "All")}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All branches</SelectItem>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Required — the branch this transfer belongs to. Also narrows the warehouse choices below.</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">From warehouse</Label> <Label className="text-base">From warehouse</Label>
@@ -160,7 +209,7 @@ export default function NewTransferPage() {
<SelectValue placeholder="Select warehouse" /> <SelectValue placeholder="Select warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(warehouses ?? []).map((w) => ( {visibleWarehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base"> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name} {w.code} — {w.name}
</SelectItem> </SelectItem>
@@ -192,7 +241,7 @@ export default function NewTransferPage() {
<SelectValue placeholder="Select warehouse" /> <SelectValue placeholder="Select warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(warehouses ?? []) {visibleWarehouses
.filter((w) => w.warehouseId !== srcWarehouseId) .filter((w) => w.warehouseId !== srcWarehouseId)
.map((w) => ( .map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base"> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
@@ -205,7 +254,7 @@ export default function NewTransferPage() {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}> <Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" /> <Plus className="size-5" />
@@ -213,6 +262,68 @@ export default function NewTransferPage() {
</Button> </Button>
</div> </div>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{lines.map((line) => (
<div key={line.key} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item</Label>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Destination bin</Label>
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{destBins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm">Qty</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={line.qty}
aria-invalid={!!lineErrors[line.key]}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
</div>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -275,16 +386,17 @@ export default function NewTransferPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
{submitError && ( {submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Transfer"} {submitting ? "Creating…" : "Create Transfer"}
</Button> </Button>
</div> </div>
@@ -2,41 +2,75 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeftRight, Plus } from "lucide-react" import { useRouter } from "next/navigation"
import { ArrowLeftRight, Eye, Plus, Search } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers" import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockTransferSummary } from "@/types/stock" import { StockTransferSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { TransferStatusBadge } from "@/components/stock/status-badges" import { TransferStatusBadge } from "@/components/stock/status-badges"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function TransfersListPage() { export default function TransfersListPage() {
const router = useRouter()
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null) const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([ const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
stockTransfersApi.list({ pageSize: 50, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined }), return () => clearTimeout(timeout)
warehousesApi.list(), }, [searchInput])
])
.then(([t, wh]) => { useEffect(() => setPage(1), [query])
setTransfers(t.items)
setWarehouses(wh.items) useEffect(() => {
setError(null)
stockTransfersApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.then((res) => {
setTransfers(res.items)
setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [activeBranchId, branchingEnabled]) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(() => {
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([]))
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}` const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -47,20 +81,45 @@ export default function TransfersListPage() {
<p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p> <p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
New Transfer New Transfer
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 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-12 w-full pl-11 text-base"
aria-label="Search stock transfers"
/>
</div>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && transfers === null && ( {!error && transfers === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -68,43 +127,128 @@ export default function TransfersListPage() {
{!error && transfers !== null && transfers.length === 0 && ( {!error && transfers !== null && transfers.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ArrowLeftRight className="size-12 text-muted-foreground" /> <ArrowLeftRight className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No transfers yet.</p> <p className="text-base text-muted-foreground">{hasFilters ? "No transfers match your search." : "No transfers yet."}</p>
{!hasFilters && (
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" /> <Plus className="size-5" />
New Transfer New Transfer
</Link> </Link>
)}
</div> </div>
)} )}
{!error && transfers !== null && transfers.length > 0 && ( {!error && transfers !== null && transfers.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{transfers.map((t) => (
<div
key={t.transferId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/stock/transfers/${t.transferId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/stock/transfers/${t.transferId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{t.docNo}</p>
<TransferStatusBadge status={t.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">From</p>
<p className="font-medium text-foreground">{whCode(t.srcWarehouseId)}</p>
</div>
<div>
<p className="text-muted-foreground">To</p>
<p className="font-medium text-foreground">{whCode(t.destWarehouseId)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">{new Date(t.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
))}
{pagination && transfers.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead> <TableHead className="w-40">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">From</TableHead> <TableHead className="w-32">From</TableHead>
<TableHead className="h-12 px-3 text-sm">To</TableHead> <TableHead className="w-32">To</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="w-28">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead className="w-40">Created</TableHead>
<TableHead className="w-20 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{transfers.map((t) => ( {transfers.map((t) => (
<TableRow key={t.transferId}> <TableRow
<TableCell className="px-3 py-3.5"> key={t.transferId}
<Link href={`/dashboard/stock/transfers/${t.transferId}`} className="font-medium text-primary hover:underline"> className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/stock/transfers/${t.transferId}`)
}}
>
<TableCell>
<Link href={`/dashboard/stock/transfers/${t.transferId}`} className="font-semibold text-foreground hover:underline">
{t.docNo} {t.docNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{whCode(t.srcWarehouseId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{whCode(t.srcWarehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{whCode(t.destWarehouseId)}</TableCell> <TableCell className="text-sm text-muted-foreground">{whCode(t.destWarehouseId)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell>
<TransferStatusBadge status={t.status} /> <TransferStatusBadge status={t.status} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{new Date(t.createdAt).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(t.createdAt).toLocaleString()}</TableCell>
<TableCell data-no-row-nav>
<div className="flex items-center justify-center">
<Link
href={`/dashboard/stock/transfers/${t.transferId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${t.docNo}`}
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -2,7 +2,7 @@
import { Suspense, useEffect, useMemo, useState } from "react" import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { BadgeDollarSign } from "lucide-react" import { BadgeDollarSign, Boxes, Layers } from "lucide-react"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
@@ -19,6 +19,21 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
/** Small tinted stat chip — value + label + icon. Matches the vendor/customer profile pages. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Boxes; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div>
<p className="text-lg font-bold leading-tight text-foreground">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
)
}
function ValuationContent() { function ValuationContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
@@ -53,6 +68,17 @@ function ValuationContent() {
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [itemId, warehouseId]) }, [itemId, warehouseId])
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mql = window.matchMedia("(max-width: 639px)")
setIsMobile(mql.matches)
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId]) const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
const visibleWarehouses = useMemo( const visibleWarehouses = useMemo(
() => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])), () => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
@@ -126,19 +152,10 @@ function ValuationContent() {
<Skeleton className="h-48 w-full" /> <Skeleton className="h-48 w-full" />
) : ( ) : (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-xl border p-5"> <StatChip icon={Boxes} label="Item" value={item?.sku ?? `#${itemId}`} />
<p className="text-sm text-muted-foreground">Item</p> <StatChip icon={Layers} label="Total quantity" value={String(valuation.totalQty)} />
<p className="text-lg font-semibold">{item?.sku ?? `#${itemId}`}</p> <StatChip icon={BadgeDollarSign} label={`Total value (${valuation.currency})`} value={valuation.totalValue.toFixed(2)} />
</div>
<div className="rounded-xl border p-5">
<p className="text-sm text-muted-foreground">Total quantity</p>
<p className="text-lg font-semibold">{valuation.totalQty}</p>
</div>
<div className="rounded-xl border p-5">
<p className="text-sm text-muted-foreground">Total value ({valuation.currency})</p>
<p className="text-lg font-semibold">{valuation.totalValue.toFixed(2)}</p>
</div>
</div> </div>
{valuation.layers.length === 0 ? ( {valuation.layers.length === 0 ? (
@@ -147,12 +164,29 @@ function ValuationContent() {
<> <>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border p-5"> <div className="rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Value by FIFO layer</p> <p className="mb-3 text-sm font-medium text-muted-foreground">Stock value by purchase batch</p>
<BarChart <BarChart
labels={valuation.layers.map((l) => new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }))} // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 layers,
datasets={[{ label: `Value (${valuation.currency})`, data: valuation.layers.map((l) => l.value), backgroundColor: "rgba(59,130,246,0.6)" }]} // Chart.js would otherwise center the lone category across the full width.
barPercentage={0.4} // Trailing blank slots keep the axis/gridlines spanning the full card while
categoryPercentage={0.95} // the real bars sit at the start. Skipped on mobile — there the bars should
// stretch to fill the narrower card instead.
labels={[
...valuation.layers.map((l) => new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })),
...(isMobile ? [] : Array(Math.max(0, 5 - valuation.layers.length)).fill("")),
]}
datasets={[
{
label: `Value (${valuation.currency})`,
data: [
...valuation.layers.map((l) => l.value),
...(isMobile ? [] : Array(Math.max(0, 5 - valuation.layers.length)).fill(null)),
],
backgroundColor: "rgba(34,197,94,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
/> />
</div> </div>
@@ -164,42 +198,63 @@ function ValuationContent() {
{ {
label: `Value (${valuation.currency})`, label: `Value (${valuation.currency})`,
data: valuation.layers.map((l) => l.value), data: valuation.layers.map((l) => l.value),
borderColor: "#3b82f6", borderColor: "rgba(34,197,94,1)",
backgroundColor: "rgba(59,130,246,0.15)", backgroundColor: "rgba(34,197,94,0.15)",
}, },
{ {
label: `Cumulative value (${valuation.currency})`, label: `Cumulative value (${valuation.currency})`,
data: valuation.layers.reduce<number[]>((acc, l) => [...acc, (acc[acc.length - 1] ?? 0) + l.value], []), data: valuation.layers.reduce<number[]>((acc, l) => [...acc, (acc[acc.length - 1] ?? 0) + l.value], []),
borderColor: "#22c55e", borderColor: "rgba(239,68,68,1)",
backgroundColor: "rgba(34,197,94,0.15)", backgroundColor: "rgba(239,68,68,0.15)",
}, },
]} ]}
/> />
</div> </div>
</div> </div>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{valuation.layers.map((layer) => (
<div key={layer.layerId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-foreground">Layer #{layer.layerId}</span>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(layer.receiptDate).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{layer.qtyRemaining} @ {layer.unitCost.toFixed(2)}</span>
<span className="font-semibold text-foreground">{layer.value.toFixed(2)}</span>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Layer</TableHead> <TableHead className="w-28">Layer</TableHead>
<TableHead className="h-12 px-3 text-sm">Receipt date</TableHead> <TableHead className="w-40">Receipt date</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty remaining</TableHead> <TableHead className="w-32 text-right">Qty remaining</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead> <TableHead className="w-32 text-right">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Value</TableHead> <TableHead className="w-32 text-right">Value</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{valuation.layers.map((layer) => ( {valuation.layers.map((layer) => (
<TableRow key={layer.layerId}> <TableRow key={layer.layerId} className="align-top hover:bg-muted/20">
<TableCell className="px-3 py-3.5">{layer.layerId}</TableCell> <TableCell className="font-semibold text-foreground">{layer.layerId}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{layer.qtyRemaining}</TableCell>
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{layer.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{layer.value.toFixed(2)}</TableCell> <TableCell className="text-right font-semibold text-foreground tabular-nums">{layer.value.toFixed(2)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
</> </>
)} )}
</> </>
@@ -276,11 +276,11 @@ export default function NewWastagePage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)} )}
<div className="flex justify-end gap-3"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}> <Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" variant="destructive" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" variant="destructive" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Recording…" : "Record Wastage"} {submitting ? "Recording…" : "Record Wastage"}
</Button> </Button>
</div> </div>
@@ -14,13 +14,16 @@ import { ReasonCode } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
export default function WastagePage() { export default function WastagePage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
@@ -32,6 +35,8 @@ export default function WastagePage() {
const [warehouseId, setWarehouseId] = useState<number | "All">("All") const [warehouseId, setWarehouseId] = useState<number | "All">("All")
const [reasonCodeId, setReasonCodeId] = useState<number | "All">("All") const [reasonCodeId, setReasonCodeId] = useState<number | "All">("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
useEffect(() => { useEffect(() => {
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
@@ -49,15 +54,23 @@ export default function WastagePage() {
warehouseId: warehouseId === "All" ? undefined : warehouseId, warehouseId: warehouseId === "All" ? undefined : warehouseId,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId, reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId,
pageSize: 200,
}) })
.then(setRecords) .then(setRecords)
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [warehouseId, reasonCodeId, activeBranchId, branchingEnabled]) }, [warehouseId, reasonCodeId, activeBranchId, branchingEnabled])
useEffect(() => setPage(1), [warehouseId, reasonCodeId])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items]) const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
const reasonById = useMemo(() => new Map((reasonCodes ?? []).map((r) => [r.reasonCodeId, r])), [reasonCodes]) const reasonById = useMemo(() => new Map((reasonCodes ?? []).map((r) => [r.reasonCodeId, r])), [reasonCodes])
const pagination = records
? { page, pageSize, totalItems: records.length, totalPages: Math.max(1, Math.ceil(records.length / pageSize)) }
: null
const pagedRecords = records?.slice((page - 1) * pageSize, page * pageSize) ?? null
const totals = useMemo(() => { const totals = useMemo(() => {
if (!records) return { qty: 0, value: 0 } if (!records) return { qty: 0, value: 0 }
return records.reduce( return records.reduce(
@@ -79,7 +92,7 @@ export default function WastagePage() {
</p> </p>
</div> </div>
</div> </div>
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }))}> <Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" /> <Plus className="size-5" />
Record Wastage Record Wastage
</Link> </Link>
@@ -138,9 +151,21 @@ export default function WastagePage() {
</div> </div>
{loading && !error && ( {loading && !error && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -152,45 +177,113 @@ export default function WastagePage() {
</div> </div>
)} )}
{!loading && !error && records.length > 0 && ( {!loading && !error && pagedRecords !== null && pagedRecords.length > 0 && (
<Table className="text-base"> <>
<TableHeader> {/* Mobile: stacked cards, "Show more" instead of pagination. */}
<TableRow> <div className="flex flex-col gap-3 md:hidden">
<TableHead className="h-12 px-3 text-sm">Date</TableHead> {records!.slice(0, page * pageSize).map((r) => {
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty wasted</TableHead>
<TableHead className="h-12 px-3 text-sm">Value (LKR)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => {
const item = itemsById.get(r.itemId) const item = itemsById.get(r.itemId)
const wh = warehousesById.get(r.warehouseId) const wh = warehousesById.get(r.warehouseId)
const reason = reasonById.get(r.reasonCodeId) const reason = reasonById.get(r.reasonCodeId)
return ( return (
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`}> <div key={`${r.adjustmentId}-${r.adjLineId}`} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell> <div className="flex items-start justify-between gap-2">
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell> <div className="min-w-0">
<TableCell className="px-3 py-3.5"> <p className="truncate font-semibold text-foreground">{item?.sku ?? `#${r.itemId}`}</p>
<div className="font-medium">{item?.sku ?? `#${r.itemId}`}</div> <p className="text-sm text-muted-foreground">{item?.name}</p>
<div className="text-sm text-muted-foreground">{item?.name}</div> </div>
</TableCell> <Badge variant="outline" className="h-6 w-fit shrink-0 justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive">
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${r.warehouseId}`}</TableCell> {reason?.description ?? `#${r.reasonCodeId}`}
<TableCell className="px-3 py-3.5"> </Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Doc No</p>
<p className="font-medium text-foreground">{r.docNo}</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
<p className="font-medium text-foreground">{wh?.code ?? `#${r.warehouseId}`}</p>
</div>
<div>
<p className="text-muted-foreground">Qty wasted</p>
<p className="font-semibold text-destructive">-{r.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Value (LKR)</p>
<p className="font-medium text-foreground">{r.value.toFixed(2)}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(r.createdAt).toLocaleString()}</p>
</div>
</div>
</div>
)
})}
{records && page * pageSize < records.length && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={() => setPage((p) => p + 1)}>
Show more
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-32">Doc No</TableHead>
<TableHead className="w-40">Date</TableHead>
<TableHead className="w-28">SKU</TableHead>
<TableHead className="w-48">Item name</TableHead>
<TableHead className="w-28">Warehouse</TableHead>
<TableHead className="w-36">Reason</TableHead>
<TableHead className="w-28 text-right">Qty wasted</TableHead>
<TableHead className="w-28 text-right">Value (LKR)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedRecords.map((r) => {
const item = itemsById.get(r.itemId)
const wh = warehousesById.get(r.warehouseId)
const reason = reasonById.get(r.reasonCodeId)
return (
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${r.itemId}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item?.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${r.warehouseId}`}</TableCell>
<TableCell>
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive"> <Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive">
{reason?.description ?? `#${r.reasonCodeId}`} {reason?.description ?? `#${r.reasonCodeId}`}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 font-medium text-destructive">-{r.qty}</TableCell> <TableCell className="text-right font-semibold text-destructive tabular-nums">-{r.qty}</TableCell>
<TableCell className="px-3 py-3.5">{r.value.toFixed(2)}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.value.toFixed(2)}</TableCell>
</TableRow> </TableRow>
) )
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</>
)} )}
</div> </div>
) )
+137 -22
View File
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, PackageCheck, Pencil, Save, Truck } from "lucide-react" import { AlertTriangle, ArrowLeft, BadgeDollarSign, PackageCheck, Pencil, Save, ShoppingCart, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { grnsApi } from "@/lib/api/grns" import { grnsApi } from "@/lib/api/grns"
@@ -19,11 +19,14 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart" import BarChart from "@/components/ui/bar-chart"
const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Small tinted stat chip for the profile header — value + label + icon. */ /** Small tinted stat chip for the profile header — value + label + icon. */
function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) { function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) {
return ( return (
@@ -126,18 +129,42 @@ export default function VendorDetailPage() {
const totalPurchased = useMemo(() => (grns ?? []).reduce((sum, g) => sum + g.totalValue, 0), [grns]) const totalPurchased = useMemo(() => (grns ?? []).reduce((sum, g) => sum + g.totalValue, 0), [grns])
const recentGrns = useMemo( const recentGrns = useMemo(
() => (grns ?? []).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 8), () => (grns ?? []).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[grns] [grns]
) )
const [grnPage, setGrnPage] = useState(1)
const [grnPageSize, setGrnPageSize] = useState(GRN_PAGE_SIZE_OPTIONS[0])
const [mobileGrnCount, setMobileGrnCount] = useState(GRN_PAGE_SIZE_OPTIONS[0])
const grnPagination = {
page: grnPage,
pageSize: grnPageSize,
totalItems: recentGrns.length,
totalPages: Math.max(1, Math.ceil(recentGrns.length / grnPageSize)),
}
const pagedGrns = recentGrns.slice((grnPage - 1) * grnPageSize, grnPage * grnPageSize)
const monthlyPurchases = useMemo(() => { const monthlyPurchases = useMemo(() => {
const byMonth = new Map<string, number>() const byMonth = new Map<string, number>()
for (const g of grns ?? []) { for (const g of grns ?? []) {
const key = g.createdAt.slice(0, 7) const key = g.createdAt.slice(0, 7)
byMonth.set(key, (byMonth.get(key) ?? 0) + g.totalValue) byMonth.set(key, (byMonth.get(key) ?? 0) + g.totalValue)
} }
return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-6) return [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).slice(-5)
}, [grns]) }, [grns])
// Lazy useState initializer, not useMemo: computing "now" is inherently impure, and the
// blessed way to do it once (rather than on every render) is a one-time init function.
// Below `sm`, skip the fixed-slot axis padding so a handful of bars stretch to fill the
// narrow card instead of leaving blank trailing slots — there's less width to spare there.
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mql = window.matchMedia("(max-width: 639px)")
setIsMobile(mql.matches)
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
async function handleSave() { async function handleSave() {
setSaveError(null) setSaveError(null)
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
@@ -237,14 +264,15 @@ export default function VendorDetailPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button size="lg" variant="outline" onClick={openEdit}> <Button size="lg" variant="outline" className="w-full sm:w-auto" onClick={openEdit}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</Button> </Button>
<Button <Button
size="lg" size="lg"
variant={vendor.status === "Active" ? "destructive" : "success"} variant={vendor.status === "Active" ? "destructive" : "success"}
className="w-full sm:w-auto"
onClick={handleToggleStatus} onClick={handleToggleStatus}
disabled={togglingStatus} disabled={togglingStatus}
> >
@@ -253,7 +281,7 @@ export default function VendorDetailPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} /> <StatChip icon={BadgeDollarSign} label="Total purchased" value={grns === null ? "…" : `LKR ${totalPurchased.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
<StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} /> <StatChip icon={PackageCheck} label="Goods receipts (GRNs)" value={grns === null ? "…" : String(grns.length)} />
<StatChip <StatChip
@@ -281,13 +309,35 @@ export default function VendorDetailPage() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5"> <div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
<ShoppingCart className="size-4.5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Monthly purchases</h2> <h2 className="text-base font-semibold text-foreground">Monthly purchases</h2>
</div>
{grns === null ? ( {grns === null ? (
<Skeleton className="h-56 w-full" /> <Skeleton className="h-56 w-full" />
) : monthlyPurchases.length > 0 ? ( ) : monthlyPurchases.length > 0 ? (
<BarChart <BarChart
labels={monthlyPurchases.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" }))} // Padded to a fixed 5-slot axis on larger screens: with fewer than 5 months of
datasets={[{ label: "Purchased (LKR)", data: monthlyPurchases.map(([, v]) => v), backgroundColor: "rgba(59,130,246,0.6)" }]} // activity, Chart.js would otherwise center the lone category across the full
// width. Trailing blank slots keep the axis/gridlines spanning the full card
// while the real bars sit at the start. Skipped on mobile — there the bars
// should stretch to fill the narrower card instead.
labels={[
...monthlyPurchases.map(([m]) => new Date(`${m}-01`).toLocaleDateString(undefined, { month: "short", year: "2-digit" })),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyPurchases.length)).fill("")),
]}
datasets={[
{
label: "Purchased (LKR)",
data: [
...monthlyPurchases.map(([, v]) => v),
...(isMobile ? [] : Array(Math.max(0, 5 - monthlyPurchases.length)).fill(null)),
],
backgroundColor: "rgba(34,197,94,0.6)",
},
]}
barPercentage={0.6}
categoryPercentage={0.6}
/> />
) : ( ) : (
<p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No purchases recorded yet.</p> <p className="flex h-56 items-center justify-center text-sm text-muted-foreground">No purchases recorded yet.</p>
@@ -297,34 +347,99 @@ export default function VendorDetailPage() {
<div className="flex flex-col gap-3 rounded-xl border p-5"> <div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent purchases (GRNs)</h2> <h2 className="text-base font-semibold text-foreground">Recent purchases (GRNs)</h2>
{grns === null ? ( {grns === null ? (
<Skeleton className="h-56 w-full" /> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
) : recentGrns.length === 0 ? ( ) : recentGrns.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No goods received from this vendor yet.</p> <p className="py-6 text-center text-sm text-muted-foreground">No goods received from this vendor yet.</p>
) : ( ) : (
<Table className="text-sm"> <>
{/* Mobile: stacked cards, "Show more" instead of pagination. */}
<div className="flex flex-col gap-2 md:hidden">
{recentGrns.slice(0, mobileGrnCount).map((g) => (
<Link
key={g.grnId}
href={`/dashboard/receiving/grn/${g.grnId}`}
className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm hover:bg-muted/20"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-foreground">{g.docNo}</span>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{g.lineCount} line{g.lineCount === 1 ? "" : "s"}</span>
<span className="font-medium text-foreground">LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</Link>
))}
{mobileGrnCount < recentGrns.length && (
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => setMobileGrnCount((c) => c + GRN_PAGE_SIZE_OPTIONS[0])}
>
Show more
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-9 px-2 text-xs">Date</TableHead> <TableHead>Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead> <TableHead>Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Lines</TableHead> <TableHead className="text-right">Lines</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead> <TableHead className="text-right">Value</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{recentGrns.map((g) => ( {pagedGrns.map((g) => (
<TableRow key={g.grnId}> <TableRow key={g.grnId} className="align-top hover:bg-muted/20">
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</TableCell> <TableCell className="text-sm text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2"> <TableCell>
<Link href={`/dashboard/receiving/grn/${g.grnId}`} className="font-medium text-primary hover:underline"> <Link href={`/dashboard/receiving/grn/${g.grnId}`} className="font-semibold text-foreground hover:underline">
{g.docNo} {g.docNo}
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="px-2 py-2">{g.lineCount}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">{g.lineCount}</TableCell>
<TableCell className="px-2 py-2">LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell> <TableCell className="text-right text-sm text-muted-foreground tabular-nums">
LKR {g.totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={grnPagination}
onPageChange={setGrnPage}
pageSizeOptions={GRN_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setGrnPage(1); setGrnPageSize(size) }}
/>
</div>
</>
)} )}
</div> </div>
</div> </div>
+168 -24
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, Trash2, Truck } from "lucide-react" import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -36,7 +36,10 @@ import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export default function VendorsPage() { export default function VendorsPage() {
const router = useRouter() const router = useRouter()
@@ -51,6 +54,7 @@ export default function VendorsPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [name, setName] = useState("") const [name, setName] = useState("")
@@ -65,6 +69,7 @@ export default function VendorsPage() {
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([]) const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : "" const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
@@ -79,15 +84,21 @@ export default function VendorsPage() {
function load() { function load() {
vendorsApi vendorsApi
.list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize: PAGE_SIZE }) .list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize })
.then((res) => { .then((res) => {
setVendors(res.items) setVendors(res.items)
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
} }
useEffect(load, [query, status, page]) function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [query, status, page, pageSize])
useEffect(() => { useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {}) vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
@@ -263,9 +274,21 @@ export default function VendorsPage() {
)} )}
{!error && vendors === null && ( {!error && vendors === null && (
<div className="flex flex-col gap-3"> <div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))} ))}
</div> </div>
)} )}
@@ -281,38 +304,139 @@ export default function VendorsPage() {
{!error && visibleVendors !== null && visibleVendors.length > 0 && ( {!error && visibleVendors !== null && visibleVendors.length > 0 && (
<> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{visibleVendors.map((v) => (
<div
key={v.vendorId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/vendors/${v.vendorId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/vendors/${v.vendorId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{v.code}</p>
<p className="text-sm text-muted-foreground">{v.name}</p>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Terms</p>
<p className="font-medium text-foreground">{v.terms ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Currency</p>
<p className="font-medium text-foreground">{v.currency}</p>
</div>
{isAdmin && branchingEnabled && (
<div className="col-span-2">
<p className="text-muted-foreground">Branch</p>
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{branchName(v.branchId)}
</Badge>
</div>
)}
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Update ${v.code}`}
title="Edit"
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${v.code}`}
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${v.code}?`}
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(v)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && visibleVendors.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="h-12 px-3 text-sm">Code</TableHead> <TableHead className="w-32">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead>Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Terms</TableHead> <TableHead>Terms</TableHead>
<TableHead className="h-12 px-3 text-sm">Currency</TableHead> <TableHead>Currency</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>} {isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="w-24 text-center">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{visibleVendors.map((v) => ( {visibleVendors.map((v) => (
<TableRow <TableRow
key={v.vendorId} key={v.vendorId}
className="cursor-pointer hover:bg-muted/40" className="align-top cursor-pointer hover:bg-muted/20"
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/vendors/${v.vendorId}`) router.push(`/dashboard/vendors/${v.vendorId}`)
}} }}
> >
<TableCell className="px-3 py-3.5 font-medium">{v.code}</TableCell> <TableCell>
<TableCell className="px-3 py-3.5">{v.name}</TableCell> <Link href={`/dashboard/vendors/${v.vendorId}`} className="font-semibold text-foreground hover:underline">
<TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell> {v.code}
<TableCell className="px-3 py-3.5">{v.currency}</TableCell> </Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.terms ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.currency}</TableCell>
{isAdmin && branchingEnabled && ( {isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(v.branchId)}</Badge> <Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(v.branchId)}</Badge>
</TableCell> </TableCell>
)} )}
<TableCell className="px-3 py-3.5"> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@@ -323,12 +447,21 @@ export default function VendorsPage() {
{v.status} {v.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav> <TableCell data-no-row-nav>
<div className="flex items-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${v.code}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/vendors/${v.vendorId}`} href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Update ${v.code}`} aria-label={`Update ${v.code}`}
title="Edit"
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
@@ -361,8 +494,19 @@ export default function VendorsPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && (
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</> </>
)} )}
</div> </div>
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import { ArrowLeftRight, Boxes, ChevronLeft, ChevronRight, MapPinned, Plus } from "lucide-react" import { ArrowLeftRight, Boxes, MapPinned, Plus } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
@@ -23,6 +23,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -36,54 +37,43 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Reusable pager for a client-side-sliced table — data for these tables is fetched in /** Reusable pager for a client-side-sliced table — data for these tables is fetched in
* full up front, so paging is done in-memory rather than via query params. */ * full up front, so paging is done in-memory rather than via query params. */
function TablePager({ function TablePager({
page, pageSize, total, onPageChange, onPageSizeChange, itemLabel, page, pageSize, total, onPageChange, onPageSizeChange,
}: { }: {
page: number page: number
pageSize: number pageSize: number
total: number total: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void onPageSizeChange: (pageSize: number) => void
itemLabel: string
}) { }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize)) const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (total === 0) return null if (total === 0) return null
return ( return (
<div className="mt-4 flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <>
<div className="flex items-center gap-3"> {/* Mobile: "Show more" instead of pagination — grows how many of the already-loaded
<p className="text-sm text-muted-foreground"> rows are sliced into the card list, rather than windowing to one page. */}
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>– {page * pageSize < total && (
<span className="font-medium text-foreground">{Math.min(page * pageSize, total)}</span>{" "} <Button
of <span className="font-medium text-foreground">{total}</span> {itemLabel} type="button"
</p> variant="outline"
<label className="flex items-center gap-1.5 text-sm text-muted-foreground"> size="lg"
Rows per page className="mt-4 w-full md:hidden"
<select onClick={() => onPageChange(page + 1)}
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) => ( Show more
<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> </Button>
)}
{/* Desktop: full pager. */}
<div className="mt-4 hidden md:block">
<PaginationFooter
pagination={{ page, pageSize, totalItems: total, totalPages }}
onPageChange={onPageChange}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={onPageSizeChange}
/>
</div> </div>
</div> </>
) )
} }
@@ -268,7 +258,18 @@ export default function WarehouseDetailPage() {
</div> </div>
) : ( ) : (
<div className="mt-5"> <div className="mt-5">
<div className="overflow-hidden rounded-xl border"> {/* Mobile: stacked rows. */}
<div className="flex flex-col gap-2 md:hidden">
{bins.slice(0, binsPage * binsPageSize).map((b) => (
<div key={b.binId} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm">
<span className="font-medium text-foreground">{b.code}</span>
<span className="text-muted-foreground">{b.binType ?? "—"}</span>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -294,7 +295,6 @@ export default function WarehouseDetailPage() {
total={bins.length} total={bins.length}
onPageChange={setBinsPage} onPageChange={setBinsPage}
onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }} onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }}
itemLabel="bins"
/> />
</div> </div>
)} )}
@@ -332,13 +332,51 @@ export default function WarehouseDetailPage() {
<BarChart <BarChart
labels={chartLabels} labels={chartLabels}
datasets={[ datasets={[
{ label: "On hand", data: chartRows.map((r) => r.onHand), backgroundColor: "#6366f1" }, { label: "On hand", data: chartRows.map((r) => r.onHand), backgroundColor: "rgba(239,68,68,0.6)" },
{ label: "Available", data: chartRows.map((r) => r.available), backgroundColor: "#22c55e" }, { label: "Available", data: chartRows.map((r) => r.available), backgroundColor: "rgba(34,197,94,0.6)" },
]} ]}
/> />
</div> </div>
<div className="mt-6 overflow-hidden rounded-xl border"> {/* Mobile: stacked cards. */}
<div className="mt-6 flex flex-col gap-3 md:hidden">
{stockRows.slice(0, stockPage * stockPageSize).map((row) => {
const item = itemsById.get(row.itemId)
return (
<div key={row.itemId} className="flex flex-col gap-3 rounded-xl border p-4">
<div>
<p className="font-medium text-foreground">{item?.sku ?? `#${row.itemId}`}</p>
<p className="text-sm text-muted-foreground">{item?.name}</p>
</div>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">On hand</p>
<p className="font-medium text-foreground">{row.onHand}</p>
</div>
<div>
<p className="text-muted-foreground">Available</p>
<p className="font-semibold text-foreground">{row.available}</p>
</div>
<div>
<p className="text-muted-foreground">On hold</p>
<p className="font-medium text-foreground">{row.onHold}</p>
</div>
<div>
<p className="text-muted-foreground">In transit</p>
<p className="font-medium text-foreground">{row.inTransit}</p>
</div>
<div>
<p className="text-muted-foreground">Reserved</p>
<p className="font-medium text-foreground">{row.reserved}</p>
</div>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="mt-6 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -378,7 +416,6 @@ export default function WarehouseDetailPage() {
total={stockRows.length} total={stockRows.length}
onPageChange={setStockPage} onPageChange={setStockPage}
onPageSizeChange={(size) => { setStockPageSize(size); setStockPage(1) }} onPageSizeChange={(size) => { setStockPageSize(size); setStockPage(1) }}
itemLabel="items"
/> />
</> </>
)} )}
@@ -414,13 +451,34 @@ export default function WarehouseDetailPage() {
<BarChart <BarChart
labels={movementChartLabels} labels={movementChartLabels}
datasets={[ datasets={[
{ label: "In", data: movementChartRows.map((r) => r.in), backgroundColor: "#22c55e" }, { label: "In", data: movementChartRows.map((r) => r.in), backgroundColor: "rgba(34,197,94,0.6)" },
{ label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "#ef4444" }, { label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "rgba(239,68,68,0.6)" },
]} ]}
/> />
</div> </div>
<div className="mt-6 overflow-hidden rounded-xl border"> {/* Mobile: stacked cards. */}
<div className="mt-6 flex flex-col gap-2 md:hidden">
{movementByItem.slice(0, movementPage * movementPageSize).map((row) => {
const item = itemsById.get(row.itemId)
return (
<div key={row.itemId} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div>
<span className="font-medium text-foreground">{item?.sku ?? `#${row.itemId}`}</span>
<span className="ml-1.5 text-muted-foreground">{item?.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-success">In +{row.in}</span>
<span className="text-destructive">Out −{row.out}</span>
<span className="font-medium text-foreground">Net {row.in - row.out}</span>
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="mt-6 hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -456,7 +514,6 @@ export default function WarehouseDetailPage() {
total={movementByItem.length} total={movementByItem.length}
onPageChange={setMovementPage} onPageChange={setMovementPage}
onPageSizeChange={(size) => { setMovementPageSize(size); setMovementPage(1) }} onPageSizeChange={(size) => { setMovementPageSize(size); setMovementPage(1) }}
itemLabel="items"
/> />
</> </>
)} )}
@@ -3,18 +3,19 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react" import { Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { Bin, Warehouse } from "@/types/master-data" import { Bin, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -28,6 +29,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -41,6 +43,9 @@ function warehouseCodeBase(name: string): string {
} }
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
/** Appends a numeric suffix until the code doesn't collide with an existing one — the /** Appends a numeric suffix until the code doesn't collide with an existing one — the
* backend enforces global uniqueness (409 on conflict) but has no generation of its own. */ * backend enforces global uniqueness (409 on conflict) but has no generation of its own. */
@@ -85,6 +90,7 @@ export default function WarehousesPage() {
const [editSubmitting, setEditSubmitting] = useState(false) const [editSubmitting, setEditSubmitting] = useState(false)
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : "" const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : ""
@@ -98,6 +104,12 @@ export default function WarehousesPage() {
setBins(allBins.flat()) setBins(allBins.flat())
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch, filterBranchId]) useEffect(load, [page, pageSize, debouncedSearch, filterBranchId])
@@ -332,7 +344,80 @@ export default function WarehousesPage() {
)} )}
{!loading && !error && warehouses !== null && warehouses.length > 0 && ( {!loading && !error && warehouses !== null && warehouses.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{warehouses.map((w) => (
<div
key={w.warehouseId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
router.push(`/dashboard/warehouse/${w.warehouseId}`)
}}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/dashboard/warehouse/${w.warehouseId}`)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{w.code}</p>
<p className="text-sm text-muted-foreground">{w.name}</p>
</div>
{warehouseBranchingEnabled && w.branchName && (
<Badge variant="outline" className="h-5 w-fit shrink-0 justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{w.branchName}
</Badge>
)}
</div>
<div className="border-t pt-3 text-sm">
<p className="text-muted-foreground">Bins</p>
<p className="font-medium text-foreground">{binCount(w.warehouseId)}</p>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(w)} aria-label={`Edit ${w.code}`} title={`Edit ${w.code}`}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${w.code}`}
title={`Delete ${w.code}`}
disabled={actionPendingId === w.warehouseId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.code}?`}
description={`This permanently removes the warehouse "${w.name}". Blocked if it still has bins, stock, or documents referencing it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && warehouses.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
@@ -379,6 +464,14 @@ export default function WarehousesPage() {
<TableCell className="px-4 py-4" data-no-row-nav> <TableCell className="px-4 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/warehouse/${w.warehouseId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${w.code}`}
title="View"
>
<Eye className="size-4" />
</Link>
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(w)} aria-label={`Edit ${w.code}`} title={`Edit ${w.code}`}> <Button variant="ghost" size="icon-sm" onClick={() => openEdit(w)} aria-label={`Edit ${w.code}`} title={`Edit ${w.code}`}>
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
@@ -413,57 +506,17 @@ export default function WarehousesPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> warehouses
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
@@ -1,12 +1,15 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import OtpInput from "@/components/ui/otp-input" import OtpInput from "@/components/ui/otp-input"
import { Mail } from "lucide-react" import { Mail } from "lucide-react"
export default function ForgotOtpPage() { export default function ForgotOtpPage() {
const router = useRouter()
const [otp, setOtp] = useState("") const [otp, setOtp] = useState("")
const [secondsLeft, setSecondsLeft] = useState(60) const [secondsLeft, setSecondsLeft] = useState(60)
@@ -24,47 +27,54 @@ export default function ForgotOtpPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4"> <AuthShell>
<div className="w-full max-w-2xl rounded-xl bg-card p-8 shadow-lg border border-border"> <div className="flex flex-col items-center text-center">
<div className="flex flex-col items-center mb-8"> <div className="mb-4 rounded-full bg-primary/10 p-4">
<div className="bg-foreground/10 rounded-full p-4 mb-4"> <Mail className="size-6 text-primary" />
<Mail className="text-foreground size-6" />
</div> </div>
<h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1> <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>
<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"> <form
<div className="flex justify-center"> noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
router.push("/login/forgot/reset")
}}
>
<OtpInput value={otp} onChange={(v) => setOtp(v)} /> <OtpInput value={otp} onChange={(v) => setOtp(v)} />
</div>
<div className="text-center"> <div className="text-center">
{canResend ? ( {canResend ? (
<button <button
onClick={(e) => { type="button"
e.preventDefault() onClick={handleResend}
handleResend() className="text-sm font-medium text-primary transition-colors hover:text-primary/80"
}}
className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
> >
Didn't receive the code? Resend Didn&apos;t receive the code? Resend
</button> </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> <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>
<div className="flex gap-3"> <Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold">
<Link href="/login" className="flex-1"> Verify
<Button variant="outline" className="w-full h-12 rounded-full border-input text-foreground hover:bg-foreground/5">Back</Button> </Button>
</Link>
<Link href="/login/forgot/reset" className="flex-1"> <div className="text-center">
<Button className="w-full h-12 rounded-full bg-foreground text-background hover:bg-foreground/90">Verify</Button> <Link href="/login" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Back to sign in
</Link> </Link>
</div> </div>
</div> </form>
</div> </AuthShell>
</div>
) )
} }
+34 -32
View File
@@ -1,7 +1,9 @@
"use client" "use client"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { validateEmail } from "@/lib/validations" import { validateEmail } from "@/lib/validations"
@@ -18,52 +20,52 @@ export default function ForgotEmailPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 px-4 py-8"> <AuthShell>
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-border"> <div className="text-center">
<div className="mb-8 text-center"> <p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="text-2xl font-bold text-foreground">Reset your password</h1> <h1 className="mt-1 text-3xl font-bold text-foreground">Reset your password</h1>
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p> <p className="mt-3 text-base text-muted-foreground">
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>
<div className="space-y-6"> <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> <div>
<label htmlFor="email" className="block text-sm font-semibold text-foreground mb-2">Email address</label> <label htmlFor="email" className="block text-sm font-semibold text-foreground">Email Address</label>
<Input <Input
id="email" id="email"
type="email" type="email"
autoComplete="email"
value={email} value={email}
onChange={(e) => onEmailChange((e.target as HTMLInputElement).value)} onChange={(e) => onEmailChange(e.target.value)}
className="h-11 rounded-lg" className="mt-2 h-11 rounded-lg px-4 text-base"
placeholder="you@example.com" placeholder="you@example.com"
aria-invalid={emailError !== ""} aria-invalid={emailError !== ""}
/> />
{emailError !== "" && <p className="text-sm text-error mt-2 font-medium">{emailError}</p>} {emailError !== "" && <p className="mt-2 text-sm font-medium text-destructive">{emailError}</p>}
</div> </div>
<div className="flex gap-3"> <Button type="submit" className="mt-8 h-12 w-full rounded-full text-base font-semibold" disabled={emailError !== ""}>
<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 Send verification code
</Button> </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> </div>
</div> </form>
</div> </AuthShell>
</div>
) )
} }
@@ -1,7 +1,9 @@
"use client" "use client"
import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@@ -13,6 +15,17 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Check, Eye, EyeOff } from "lucide-react" import { Check, Eye, EyeOff } from "lucide-react"
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
}
export default function ForgotResetPage() { export default function ForgotResetPage() {
const router = useRouter() const router = useRouter()
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
@@ -28,39 +41,39 @@ export default function ForgotResetPage() {
const passwordsMatch = password === confirm && password.length > 0 const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
const PasswordRequirement = ({ met, label }: { met: boolean; label: string }) => (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4"> <AuthShell>
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-border"> <div className="text-center">
<div className="mb-8 text-center"> <p className="text-sm font-semibold tracking-wide text-primary uppercase">Forgot password</p>
<h1 className="text-2xl font-bold text-foreground">Create a new password</h1> <h1 className="mt-1 text-3xl font-bold text-foreground">Create a new password</h1>
<p className="mt-2 text-sm text-muted-foreground">Choose a strong password to secure your account.</p> <p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
</div> </div>
<div className="space-y-6"> <form
noValidate
className="mt-10 space-y-6"
onSubmit={(e) => {
e.preventDefault()
if (allValid) setShowSuccess(true)
}}
>
<div> <div>
<label htmlFor="password" className="block text-sm font-semibold text-foreground mb-2">New password</label> <label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label>
<div className="relative"> <div className="relative mt-2">
<Input <Input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={password} value={password}
onChange={(e) => setPassword((e.target as HTMLInputElement).value)} onChange={(e) => setPassword(e.target.value)}
className="h-11 pr-11" className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Enter your new password" placeholder="Enter your new password"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((s) => !s)} onClick={() => setShowPassword((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" 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" />} {showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button> </button>
@@ -68,27 +81,33 @@ export default function ForgotResetPage() {
</div> </div>
<div> <div>
<label htmlFor="confirm" className="block text-sm font-semibold text-foreground mb-2">Confirm password</label> <label htmlFor="confirm" className="block text-sm font-semibold text-foreground">Confirm Password</label>
<div className="relative"> <div className="relative mt-2">
<Input <Input
id="confirm" id="confirm"
type={showConfirm ? 'text' : 'password'} type={showConfirm ? "text" : "password"}
autoComplete="new-password"
value={confirm} value={confirm}
onChange={(e) => setConfirm((e.target as HTMLInputElement).value)} onChange={(e) => setConfirm(e.target.value)}
className="h-11 pr-11" className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Confirm your new password" placeholder="Confirm your new password"
aria-invalid={!passwordsMatch && confirm.length > 0}
/> />
<button <button
type="button" type="button"
onClick={() => setShowConfirm((s) => !s)} onClick={() => setShowConfirm((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" 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" />} {showConfirm ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button> </button>
</div> </div>
{!passwordsMatch && confirm.length > 0 && (
<p className="mt-2 text-sm font-medium text-destructive">Passwords don&apos;t match.</p>
)}
</div> </div>
<div className="bg-muted/50 rounded-lg p-4 space-y-3"> <div className="space-y-3 rounded-lg bg-primary/5 p-4">
<p className="text-sm font-semibold text-foreground">Password requirements:</p> <p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" /> <PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" /> <PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
@@ -96,27 +115,16 @@ export default function ForgotResetPage() {
<PasswordRequirement met={hasSpecial} label="At least 1 special character" /> <PasswordRequirement met={hasSpecial} label="At least 1 special character" />
</div> </div>
{!passwordsMatch && confirm.length > 0 && ( <Button type="submit" disabled={!allValid} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
<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 Change password
</Button> </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> </div>
</div> </form>
<Dialog open={showSuccess} onOpenChange={setShowSuccess}> <Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<DialogContent className="w-full max-w-lg sm:max-w-lg"> <DialogContent className="w-full max-w-lg sm:max-w-lg">
@@ -131,12 +139,11 @@ export default function ForgotResetPage() {
</div> </div>
</div> </div>
</DialogHeader> </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"> <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 Return to sign in
</Button> </Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </AuthShell>
</div>
) )
} }
+3 -38
View File
@@ -1,7 +1,6 @@
"use client" "use client"
import { Suspense, useState } from "react" import { Suspense, useState } from "react"
import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
@@ -12,6 +11,7 @@ import { authApi } from "@/lib/api/auth"
import { setStoredUser } from "@/lib/auth-session" import { setStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -55,35 +55,7 @@ function LoginForm() {
}) })
return ( return (
<div className="relative min-h-screen overflow-hidden"> <AuthShell>
{/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
cutout (whithoutbackground.png) so there's no white scene box floating on the
dark panel. */}
<div className="absolute inset-y-0 left-[45%] right-0 bg-white dark:bg-background">
<Image
src="/images/loginerps.png"
alt="ERP Illustration"
fill
className="object-contain object-center dark:hidden"
priority
/>
<Image
src="/images/whithoutbackground.png"
alt="ERP Illustration"
fill
className="hidden object-contain object-center dark:block"
priority
/>
</div>
{/* Login panel — overlays left side, no hard border with the photo */}
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
<div className="mb-8 flex justify-center">
<Link href="/" className="inline-flex items-center">
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
</Link>
</div>
<div className="text-center"> <div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p> <p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Welcome back</h1> <h1 className="mt-1 text-3xl font-bold text-foreground">Welcome back</h1>
@@ -174,13 +146,6 @@ function LoginForm() {
<div id="form-errors" className="sr-only" aria-live="polite"> <div id="form-errors" className="sr-only" aria-live="polite">
{Object.values(form.formState.errors).length > 0 && "There are errors in the form."} {Object.values(form.formState.errors).length > 0 && "There are errors in the form."}
</div> </div>
</AuthShell>
<div className="mt-12 text-center">
<p className="text-sm text-muted-foreground">
Don&apos;t have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link>
</p>
</div>
</main>
</div>
) )
} }
+142 -10
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User } from "lucide-react" import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User, X } from "lucide-react"
import { authApi } from "@/lib/api/auth" import { authApi } from "@/lib/api/auth"
import { searchApi } from "@/lib/api/search" import { searchApi } from "@/lib/api/search"
@@ -22,7 +22,11 @@ import { Badge } from "@/components/ui/badge"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
@@ -86,6 +90,14 @@ const STOCK_TITLES: Record<string, string> = {
"/dashboard/stock/wastage/new": "Record Wastage", "/dashboard/stock/wastage/new": "Record Wastage",
} }
const PRODUCTION_TITLES: Record<string, string> = {
"/dashboard/production": "Production",
"/dashboard/production/templates": "Production Templates",
"/dashboard/production/templates/new": "New Template",
"/dashboard/production/runs": "Production Runs",
"/dashboard/production/produced": "Produced Items",
}
function titleFromPath(pathname: string) { function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales" if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
@@ -103,6 +115,10 @@ function titleFromPath(pathname: string) {
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer" if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count" if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
if (PRODUCTION_TITLES[pathname]) return PRODUCTION_TITLES[pathname]
if (/^\/dashboard\/production\/templates\/[^/]+$/.test(pathname)) return "Template Builder"
if (/^\/dashboard\/production\/runs\/[^/]+$/.test(pathname)) return "Production Run"
if (PROCUREMENT_TITLES[pathname]) return PROCUREMENT_TITLES[pathname] if (PROCUREMENT_TITLES[pathname]) return PROCUREMENT_TITLES[pathname]
if (/^\/dashboard\/procurement\/requisitions\/[^/]+$/.test(pathname)) return "Requisition" if (/^\/dashboard\/procurement\/requisitions\/[^/]+$/.test(pathname)) return "Requisition"
if (/^\/dashboard\/procurement\/rfqs\/[^/]+$/.test(pathname)) return "RFQ" if (/^\/dashboard\/procurement\/rfqs\/[^/]+$/.test(pathname)) return "RFQ"
@@ -201,7 +217,9 @@ export function Header() {
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([]) const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([])
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
const searchBoxRef = useRef<HTMLDivElement>(null) const searchBoxRef = useRef<HTMLDivElement>(null)
const mobileSearchBoxRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const term = searchQuery.trim() const term = searchQuery.trim()
@@ -221,6 +239,9 @@ export function Header() {
if (searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) { if (searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) {
setSearchOpen(false) setSearchOpen(false)
} }
if (mobileSearchBoxRef.current && !mobileSearchBoxRef.current.contains(e.target as Node)) {
setMobileSearchOpen(false)
}
} }
document.addEventListener("mousedown", onClickOutside) document.addEventListener("mousedown", onClickOutside)
return () => document.removeEventListener("mousedown", onClickOutside) return () => document.removeEventListener("mousedown", onClickOutside)
@@ -228,6 +249,7 @@ export function Header() {
function goToSearchResult(result: GlobalSearchResult) { function goToSearchResult(result: GlobalSearchResult) {
setSearchOpen(false) setSearchOpen(false)
setMobileSearchOpen(false)
setSearchQuery("") setSearchQuery("")
setSearchResults([]) setSearchResults([])
router.push(SEARCH_TYPE_META[result.type].href(result.id)) router.push(SEARCH_TYPE_META[result.type].href(result.id))
@@ -263,22 +285,39 @@ export function Header() {
} }
return ( return (
<header className="print:hidden mx-3 mt-3 mb-3 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:gap-4 lg:p-4"> <header className="print:hidden mx-3 mt-3 mb-3 flex flex-col gap-2 rounded-3xl bg-card py-4 pr-3 pl-12 shadow-sm ring-1 ring-foreground/10 sm:mx-6 sm:pr-4 sm:pl-14 lg:mx-8 lg:mt-4 lg:mb-4 lg:p-4">
<div className="flex items-center justify-between gap-2 lg:gap-4">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
{showBackButton && ( {showBackButton && (
<button <button
type="button" type="button"
onClick={() => router.back()} onClick={() => router.back()}
aria-label="Go back" aria-label="Go back"
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground" className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground sm:size-9"
> >
<ArrowLeft className="size-5" /> <ArrowLeft className="size-5" />
</button> </button>
)} )}
<h1 className="min-w-0 truncate text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1> {/* Phones wrap to two lines rather than truncating — at this width a one-line title
is cut to a few letters ("Prod…"). One truncated line from sm, where it fits. */}
<h1 className="line-clamp-2 min-w-0 text-base leading-tight font-bold tracking-tight wrap-break-word text-foreground sm:truncate sm:text-xl sm:leading-normal">
{title}
</h1>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3"> <div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
<button
type="button"
onClick={() => setMobileSearchOpen((v) => !v)}
aria-label="Search"
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground sm:hidden",
mobileSearchOpen && "bg-muted text-foreground"
)}
>
<Search className="size-5" />
</button>
{branchingEnabled && (isAdmin ? ( {branchingEnabled && (isAdmin ? (
<Select value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES} onValueChange={handleBranchChange}> <Select value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES} onValueChange={handleBranchChange}>
<SelectTrigger <SelectTrigger
@@ -358,7 +397,8 @@ export function Header() {
)} )}
</div> </div>
<ThemeToggle /> {/* 108px wide — on phones it moves into the account menu so the title has room. */}
<ThemeToggle className="hidden sm:flex" />
<Popover onOpenChange={(open) => open && loadNotifications()}> <Popover onOpenChange={(open) => open && loadNotifications()}>
<PopoverTrigger <PopoverTrigger
@@ -375,10 +415,7 @@ export function Header() {
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary p-0" /> <Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary p-0" />
)} )}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent align="end" sideOffset={10} className="w-[calc(100vw-1.5rem)] max-w-[22rem] sm:w-96">
align="end"
className="w-[calc(100vw-2rem)] max-w-sm sm:w-96"
>
<div className="flex items-center justify-between border-b border-foreground/10 px-4 py-3"> <div className="flex items-center justify-between border-b border-foreground/10 px-4 py-3">
<p className="text-sm font-semibold text-foreground">Notifications</p> <p className="text-sm font-semibold text-foreground">Notifications</p>
{unreadCount > 0 && ( {unreadCount > 0 && (
@@ -427,7 +464,7 @@ export function Header() {
</Popover> </Popover>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted"> <DropdownMenuTrigger className="flex items-center gap-2 rounded-full p-1 outline-none hover:bg-muted sm:pr-2">
<Avatar> <Avatar>
<AvatarFallback className="bg-primary/10 font-semibold text-primary"> <AvatarFallback className="bg-primary/10 font-semibold text-primary">
{user ? initials(displayName(user)) : "?"} {user ? initials(displayName(user)) : "?"}
@@ -450,6 +487,39 @@ export function Header() {
</p> </p>
)} )}
</div> </div>
<div className="flex items-center justify-between gap-3 px-2 pb-2.5 sm:hidden">
<span className="text-sm text-muted-foreground">Theme</span>
<ThemeToggle />
</div>
{/* The header's branch picker is hidden on phones, so admins switch here. Radio
items rather than a nested Select: a Select's portaled popup would read as an
outside press and close this menu. */}
{branchingEnabled && isAdmin && (
<div className="sm:hidden">
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 text-sm">
<Building2 className="size-3.5" />
Branch
</DropdownMenuLabel>
<div className="max-h-48 overflow-y-auto">
<DropdownMenuRadioGroup
value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES}
onValueChange={(value: string) => handleBranchChange(value)}
>
<DropdownMenuRadioItem value={ALL_BRANCHES} closeOnClick className="py-2 text-base">
All Branches
</DropdownMenuRadioItem>
{branches.map((b) => (
<DropdownMenuRadioItem key={b.branchId} value={String(b.branchId)} closeOnClick className="py-2 text-base">
{b.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</div>
</DropdownMenuGroup>
</div>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
render={<Link href="/dashboard/profile" />} render={<Link href="/dashboard/profile" />}
@@ -477,6 +547,68 @@ export function Header() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div>
{mobileSearchOpen && (
<div ref={mobileSearchBoxRef} className="relative sm:hidden">
<Search className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search item, PO, invoice, employee…"
className="h-10 w-full rounded-full border border-transparent bg-muted pr-9 pl-9 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<button
type="button"
onClick={() => {
setMobileSearchOpen(false)
setSearchQuery("")
setSearchResults([])
}}
aria-label="Close search"
className="absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
>
<X className="size-4" />
</button>
{searchQuery.trim().length >= 2 && (
<div className="absolute top-full right-0 left-0 z-50 mt-2 max-h-96 overflow-y-auto rounded-xl bg-popover py-1.5 text-sm shadow-md ring-1 ring-foreground/10">
{searchResults.length === 0 ? (
<p className="p-4 text-center text-sm text-muted-foreground">No matches found.</p>
) : (
searchGrouped.map((group) => {
const meta = SEARCH_TYPE_META[group.type]
const Icon = meta.icon
return (
<div key={group.type}>
<p className="px-4 pt-1 pb-1 text-xs font-medium text-muted-foreground">{meta.label}</p>
{group.items.map((item) => (
<button
key={`${item.type}-${item.id}`}
type="button"
onClick={() => goToSearchResult(item)}
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-primary/10"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{item.title}</p>
<p className="truncate text-xs text-muted-foreground">
{item.number}
{item.subtitle ? ` · ${item.subtitle}` : ""}
</p>
</div>
</button>
))}
</div>
)
})
)}
</div>
)}
</div>
)}
</header> </header>
) )
} }
@@ -0,0 +1,40 @@
import Image from "next/image"
import Link from "next/link"
/** Sign-in page chrome shared by the auth screens: illustration on the right, the form panel
* (logo + content) overlaying the left 45%. */
export function AuthShell({ children }: { children: React.ReactNode }) {
return (
<div className="relative min-h-screen overflow-hidden">
{/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
cutout (whithoutbackground.png) so there's no white scene box floating on the
dark panel. */}
<div className="absolute inset-y-0 left-[45%] right-0 bg-white dark:bg-background">
<Image
src="/images/loginerps.png"
alt="ERP Illustration"
fill
className="object-contain object-center dark:hidden"
priority
/>
<Image
src="/images/whithoutbackground.png"
alt="ERP Illustration"
fill
className="hidden object-contain object-center dark:block"
priority
/>
</div>
{/* Form panel — overlays left side, no hard border with the photo */}
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
<div className="mb-8 flex justify-center">
<Link href="/" className="inline-flex items-center">
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
</Link>
</div>
{children}
</main>
</div>
)
}
@@ -164,7 +164,7 @@ export function RolePermissionActions({
return ( return (
<div className="overflow-hidden rounded-lg border"> <div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 border-b bg-muted/40 px-4 py-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase"> <div className="hidden items-center gap-x-4 border-b bg-muted/40 px-4 py-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase sm:grid sm:grid-cols-[minmax(9rem,14rem)_1fr]">
<span>Section</span> <span>Section</span>
<span>Actions</span> <span>Actions</span>
</div> </div>
@@ -172,7 +172,7 @@ export function RolePermissionActions({
{groups.map((group) => ( {groups.map((group) => (
<div key={group.navItemId} className="border-b last:border-b-0"> <div key={group.navItemId} className="border-b last:border-b-0">
{group.own ? ( {group.own ? (
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3"> <div className="grid grid-cols-1 gap-2 bg-white px-4 py-3 sm:grid-cols-[minmax(9rem,14rem)_1fr] sm:items-center sm:gap-x-4">
<label className="flex items-center gap-2.5"> <label className="flex items-center gap-2.5">
<SelectAllCheckbox <SelectAllCheckbox
actions={actionsFor(group.own.id, navItemActions)} actions={actionsFor(group.own.id, navItemActions)}
@@ -189,7 +189,7 @@ export function RolePermissionActions({
)} )}
{group.children.map((child) => ( {group.children.map((child) => (
<div key={child.id} className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3 pl-8"> <div key={child.id} className="grid grid-cols-1 gap-2 bg-white px-4 py-3 pl-8 sm:grid-cols-[minmax(9rem,14rem)_1fr] sm:items-center sm:gap-x-4">
<label className="flex items-center gap-2.5"> <label className="flex items-center gap-2.5">
<span className="text-muted-foreground/40">└</span> <span className="text-muted-foreground/40">└</span>
<SelectAllCheckbox <SelectAllCheckbox
@@ -54,7 +54,7 @@ export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavIte
return ( return (
<div <div
key={item.navItemId} key={item.navItemId}
className="grid grid-cols-[minmax(11rem,14rem)_1fr] items-start gap-x-4 gap-y-2 border-b bg-white px-4 py-3 last:border-b-0" className="grid grid-cols-1 gap-2 border-b bg-white px-4 py-3 last:border-b-0 sm:grid-cols-[minmax(11rem,14rem)_1fr] sm:items-start sm:gap-x-4 sm:gap-y-2"
> >
<label className="flex items-center gap-2.5 pt-0.5"> <label className="flex items-center gap-2.5 pt-0.5">
<Checkbox <Checkbox
@@ -4,7 +4,7 @@
// (Branch, Designation, EmploymentType) — identical shape to each other, so one // (Branch, Designation, EmploymentType) — identical shape to each other, so one
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages. // component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react" import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -16,11 +16,15 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50] const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
interface CodeNamed { interface CodeNamed {
code: string code: string
@@ -58,6 +62,7 @@ export function CodeNameMasterPage<T extends CodeNamed>({
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("") const [code, setCode] = useState("")
@@ -80,6 +85,12 @@ export function CodeNameMasterPage<T extends CodeNamed>({
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(load, [page, pageSize, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -212,9 +223,9 @@ export function CodeNameMasterPage<T extends CodeNamed>({
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> <FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleCreate} disabled={submitting}>{submitting ? "Creating…" : "Create"}</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -243,9 +254,9 @@ export function CodeNameMasterPage<T extends CodeNamed>({
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} /> <FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleEditSave} disabled={editSubmitting || editLoading}>
{editSubmitting ? "Saving…" : "Save"} {editSubmitting ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@@ -313,7 +324,97 @@ export function CodeNameMasterPage<T extends CodeNamed>({
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((item) => (
<div key={idOf(item)} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{item.name}</p>
<span className="font-mono text-xs text-muted-foreground">{item.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{item.status}
</Badge>
</div>
<div className="border-t pt-3 text-sm">
<p className="text-muted-foreground">Created</p>
<p className="font-medium text-foreground">
{new Date(item.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}
</p>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${item.name}`}
title={`Edit ${item.name}`}
onClick={() => openEdit(item)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
item.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
disabled={actionPendingId === idOf(item)}
onClick={() => toggleStatus(item)}
>
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`}
disabled={actionPendingId === idOf(item)}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${item.name}?`}
description="This permanently removes this record. This cannot be undone. Blocked if it's referenced by other records — deactivate it instead in that case."
confirmLabel="Delete"
onConfirm={() => handleDelete(item)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="table-fixed text-base"> <Table className="table-fixed text-base">
<TableHeader> <TableHeader>
@@ -403,57 +504,17 @@ export function CodeNameMasterPage<T extends CodeNamed>({
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> {title.toLowerCase()}
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -52,25 +52,16 @@ function fmt(n: number): string {
} }
/** /**
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied * The body of a stage card — everything but the frame. Shared by the canvas node below and the
* from the template at creation) and colored by its live status. Clicking it opens the stage * run page's phone list, so the two views of a stage can't drift apart.
* drawer — handled by the page via `onNodeClick`, not here.
*/ */
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) { export function RunStageCardBody({ data }: { data: RunStageData }) {
const color = STAGE_STATUS_COLOR[data.state] const color = STAGE_STATUS_COLOR[data.state]
const running = data.actualStartAt !== null && data.actualMinutes === null const running = data.actualStartAt !== null && data.actualMinutes === null
const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned
return ( return (
<div <>
className={cn(
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
selected ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${color}` } : undefined}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<div className="flex items-start gap-1.5"> <div className="flex items-start gap-1.5">
<span className="mt-1 size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} /> <span className="mt-1 size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<p className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{data.name}</p> <p className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{data.name}</p>
@@ -116,7 +107,26 @@ function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
{fmt(data.availableToTransfer)} to transfer {fmt(data.availableToTransfer)} to transfer
</div> </div>
)} )}
</>
)
}
/**
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
* from the template at creation) and colored by its live status. Clicking it opens the stage
* drawer — handled by the page via `onNodeClick`, not here.
*/
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
return (
<div
className={cn(
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
selected ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<RunStageCardBody data={data} />
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" /> <Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div> </div>
) )
@@ -0,0 +1,352 @@
"use client"
import { useEffect, useState } from "react"
import { salesReturnsApi } from "@/lib/api/sales-returns"
import { salesApi } from "@/lib/api/sales"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateSalesReturnLine } from "@/lib/validations/sales"
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface LineState {
selected: boolean
qty: string
}
export interface SalesReturnFormProps {
presetInvoiceId?: number | null
presetLineId?: number | null
onCancel: () => void
onSuccess: (docNo: string) => void
}
/** Shared form body behind both the standalone /sales-returns/new page (deep-linked from an
* invoice's "Return this line" action, which needs presetInvoiceId/presetLineId) and the
* "New Return" popup on the sales-returns list. */
export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, onCancel, onSuccess }: SalesReturnFormProps) {
const [invoices, setInvoices] = useState<SalesInvoice[] | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
const [remainingLoading, setRemainingLoading] = useState(false)
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [lineState, setLineState] = useState<Record<number, LineState>>({})
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
.then(([invoiceList, it, rc]) => {
// Only Posted invoices have stock movements to return against.
Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data)))
setItems(it.items)
setReasonCodes(rc.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null
useEffect(() => {
if (!selectedInvoice) {
setLineState({})
setRemainingByLine({})
return
}
const next: Record<number, LineState> = {}
for (const line of selectedInvoice.lines) {
next[line.salesInvoiceLineId] = { selected: false, qty: "" }
}
setLineState(next)
setRemainingLoading(true)
salesReturnsApi
.getRemaining(selectedInvoice.salesInvoiceId)
.then((rows) => {
const map: Record<number, number> = {}
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
setRemainingByLine(map)
if (presetLineId) {
const remaining = map[presetLineId] ?? 0
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
}
})
.catch((err) => setLoadError(errorMessage(err)))
.finally(() => setRemainingLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInvoice?.salesInvoiceId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function remainingFor(lineId: number, fallback: number) {
return remainingByLine[lineId] ?? fallback
}
function toggleLine(line: SalesInvoiceLine) {
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
setLineState((prev) => ({
...prev,
[line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) },
}))
}
function setQty(lineId: number, qty: string) {
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
if (!selectedInvoice) {
setHeaderError("Select an invoice to return against.")
return
}
if (!reasonCodeId) {
setHeaderError("Select a reason code.")
return
}
const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected)
if (selectedLines.length === 0) {
setSubmitError("Select at least one line to return.")
return
}
const nextErrors: Record<number, Record<string, string>> = {}
for (const line of selectedLines) {
const errors = validateSalesReturnLine({
salesInvoiceLineId: line.salesInvoiceLineId,
qty: lineState[line.salesInvoiceLineId].qty,
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
})
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
}
setLineErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({
salesInvoiceLineId: l.salesInvoiceLineId,
itemId: l.itemId,
qty: Number(lineState[l.salesInvoiceLineId].qty),
}))
setSubmitting(true)
try {
const salesReturn = await salesReturnsApi.create({
customerId: selectedInvoice.customerId,
warehouseId: selectedInvoice.warehouseId,
reasonCodeId,
lines: payloadLines,
})
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
onSuccess(salesReturn.docNo)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not post sales return", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !invoices
return (
<div className="flex flex-col gap-6">
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-24 w-full" />}
{!loading && (
<>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label className="text-base">Sales Invoice</Label>
<Select<number | null> value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a posted invoice" />
</SelectTrigger>
<SelectContent>
{(invoices ?? []).map((inv) => (
<SelectItem key={inv.salesInvoiceId} value={inv.salesInvoiceId} className="text-base">
{inv.invoiceNo} — {inv.customerSnapshotName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Reason</Label>
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{reasonCodes.map((rc) => (
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
{rc.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
{selectedInvoice && (
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines invoiced on {selectedInvoice.invoiceNo}</h2>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{selectedInvoice.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
const fullyReturned = !remainingLoading && remaining <= 0
return (
<div key={line.salesInvoiceLineId} className="flex flex-col gap-3 rounded-xl border p-4">
<label className="flex items-start gap-3">
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
<span className="min-w-0 truncate text-base font-medium text-foreground">
{item ? `${item.sku} — ${item.name}` : line.description}
</span>
</label>
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Invoiced</p>
<p className="font-medium text-foreground">{line.qty}</p>
</div>
<div>
<p className="text-muted-foreground">Remaining</p>
<p className="font-medium text-foreground">
{remainingLoading ? "…" : fullyReturned ? "Returned" : remaining}
</p>
</div>
<div>
<p className="text-muted-foreground">Unit price</p>
<p className="font-medium text-foreground">{line.unitPrice.toFixed(2)}</p>
</div>
</div>
<div>
<Label className="text-sm">Return qty</Label>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={state.qty}
disabled={!state.selected || fullyReturned}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
</div>
)
})}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-10" />
<TableHead>Item</TableHead>
<TableHead>Invoiced qty</TableHead>
<TableHead>Remaining qty</TableHead>
<TableHead>Unit price</TableHead>
<TableHead className="w-36">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedInvoice.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
const fullyReturned = !remainingLoading && remaining <= 0
return (
<TableRow key={line.salesInvoiceLineId} className="align-top hover:bg-muted/20">
<TableCell>
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell>{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
<TableCell className="text-sm text-muted-foreground">{line.qty}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{remainingLoading ? "…" : fullyReturned ? <span>Fully returned</span> : remaining}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected || fullyReturned}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" type="button" onClick={onCancel} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -6,7 +6,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, usePathname } from "next/navigation" import { useParams, usePathname } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { BarChart3, Building2, MapPin, Phone, ReceiptText } from "lucide-react" import { ArrowLeft, BarChart3, Building2, MapPin, Phone, ReceiptText } from "lucide-react"
import { employeesApi } from "@/lib/api/employees" import { employeesApi } from "@/lib/api/employees"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -20,13 +20,12 @@ import { buttonVariants } from "@/components/ui/button"
import PieChart from "@/components/ui/pie-chart" import PieChart from "@/components/ui/pie-chart"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
// Status colors, not a categorical series — same green/gray the Active/Inactive // Status colors — same green/red the item detail page's charts use (Purchased/Sold),
// badges already use elsewhere on this page, so the chart reads as an extension // so chart colors read as one consistent palette across the app.
// of that badge rather than an unrelated palette. const STATUS_COLORS = { active: "rgba(34,197,94,1)", inactive: "rgba(239,68,68,1)" }
const STATUS_COLORS = { active: "#22c55e", inactive: "#9ca3af" }
// Sales invoice status — same three colors as the invoice status Badge elsewhere in the app. // Sales invoice status — same green/red as the item detail page's charts, plus gray for Draft.
const SALES_STATUS_COLORS = { posted: "#22c55e", draft: "#9ca3af", cancelled: "#ef4444" } const SALES_STATUS_COLORS = { posted: "rgba(34,197,94,1)", draft: "#9ca3af", cancelled: "rgba(239,68,68,1)" }
const CURRENCY_FORMAT = new Intl.NumberFormat("en-LK", { style: "currency", currency: "LKR" }) const CURRENCY_FORMAT = new Intl.NumberFormat("en-LK", { style: "currency", currency: "LKR" })
@@ -109,9 +108,17 @@ export function BranchDetailPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <Link
href={listHref}
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
Back to Branches
</Link>
<div className="flex flex-wrap items-center gap-3">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{branch.name}</h1> <h1 className="text-2xl font-bold text-foreground">{branch.name}</h1>
<Badge <Badge
variant="outline" variant="outline"
@@ -227,10 +234,6 @@ export function BranchDetailPage() {
</div> </div>
</section> </section>
</div> </div>
<Link href={listHref} className={cn(buttonVariants({ variant: "outline" }), "ml-auto w-fit")}>
Back to branches
</Link>
</div> </div>
) )
} }
@@ -8,7 +8,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { Building2, ChevronLeft, ChevronRight, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react" import { Building2, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -22,11 +22,15 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
export function BranchMasterPage() { export function BranchMasterPage() {
const pathname = usePathname() const pathname = usePathname()
@@ -47,6 +51,7 @@ export function BranchMasterPage() {
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false)
function load() { function load() {
branchesHrmApi branchesHrmApi
@@ -56,6 +61,12 @@ export function BranchMasterPage() {
setPagination(res.pagination) setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [page, pageSize, debouncedSearch]) useEffect(load, [page, pageSize, debouncedSearch])
@@ -180,9 +191,9 @@ export function BranchMasterPage() {
<Input id="branch-phone" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} /> <Input id="branch-phone" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} />
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button> <Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button className="min-w-32" onClick={handleSubmit} disabled={submitting}> <Button className="w-full sm:w-auto sm:min-w-32" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"} {submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button> </Button>
</div> </div>
@@ -261,7 +272,109 @@ export function BranchMasterPage() {
)} )}
{!error && items !== null && items.length > 0 && ( {!error && items !== null && items.length > 0 && (
<div className="overflow-hidden rounded-xl border"> <>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((item) => (
<div key={item.branchId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{item.name}</p>
<span className="font-mono text-xs text-muted-foreground">{item.code}</span>
</div>
<Badge
variant="outline"
className={cn(
"h-6 w-fit shrink-0 justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{item.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Address</p>
<p className="font-medium text-foreground">{item.address ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Phone Number</p>
<p className="font-medium text-foreground">{item.phoneNumber ?? "—"}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3">
<Link
href={`${pathname}/${item.branchId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${item.name}`}
title={`View ${item.name}`}
>
<Eye className="size-4" />
</Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${item.name}`}
title={`Edit ${item.name}`}
onClick={() => openEditDialog(item)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
item.status === "Active"
? "text-destructive hover:bg-destructive/10"
: "text-success hover:bg-success/10"
)}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
disabled={actionPendingId === item.branchId}
onClick={() => toggleStatus(item)}
>
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`}
disabled={deletingId === item.branchId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${item.name}?`}
description="This permanently removes the branch. Branches referenced by employees, warehouses, or other records cannot be deleted."
confirmLabel="Delete"
onConfirm={() => handleDelete(item)}
/>
</AlertDialog>
</div>
</div>
))}
{pagination && items.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
{showMoreLoading ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="text-base"> <Table className="text-base">
<TableHeader> <TableHeader>
@@ -361,57 +474,17 @@ export function BranchMasterPage() {
</Table> </Table>
</div> </div>
</div> </div>
</>
)} )}
{pagination && pagination.totalItems > 0 && ( {pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between"> <div className="hidden md:block">
<div className="flex items-center gap-3"> <PaginationFooter
<p className="text-sm text-muted-foreground"> pagination={pagination}
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>– onPageChange={setPage}
<span className="font-medium text-foreground"> pageSizeOptions={PAGE_SIZE_OPTIONS}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
</span>{" "} />
of <span className="font-medium text-foreground">{pagination.totalItems}</span> branches
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1,5 +1,5 @@
"use client" "use client"
import React from "react" import React, { useEffect, useState } from "react"
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,
@@ -13,6 +13,24 @@ import { Bar } from "react-chartjs-2"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend) ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
// Matches Tailwind's `sm` breakpoint — below it, axis labels get a smaller font
// so warehouse/category codes don't crowd or overlap on narrow chart widths.
const MOBILE_QUERY = "(max-width: 639px)"
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY)
setIsMobile(mql.matches)
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
return isMobile
}
type Props = { type Props = {
labels: string[] labels: string[]
datasets: Array<{ datasets: Array<{
@@ -29,16 +47,32 @@ type Props = {
} }
export default function BarChart({ labels, datasets, height = 260, barPercentage, categoryPercentage }: Props) { export default function BarChart({ labels, datasets, height = 260, barPercentage, categoryPercentage }: Props) {
const isMobile = useIsMobile()
const data = { const data = {
labels, labels,
datasets: datasets.map((d) => ({ ...d, barPercentage, categoryPercentage })), datasets: datasets.map((d) => ({ ...d, barPercentage, categoryPercentage })),
} }
const tickFont = { size: isMobile ? 10 : 12 }
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: "top" as const } }, plugins: { legend: { position: "top" as const, labels: { font: tickFont } } },
scales: { y: { beginAtZero: true } }, scales: {
x: {
ticks: {
font: tickFont,
// Let Chart.js rotate labels to fit rather than silently dropping them via
// autoSkip — on a narrow phone width, angled text beats missing categories.
autoSkip: false,
maxRotation: 60,
minRotation: isMobile ? 45 : 0,
},
},
y: { beginAtZero: true, ticks: { font: tickFont } },
},
} }
return ( return (
@@ -17,7 +17,7 @@ export interface KpiTileSpec {
*/ */
export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) { export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
return ( return (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
{tiles {tiles
? tiles.map((tile) => ( ? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} /> <StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
@@ -38,7 +38,7 @@ export default function OtpInput({ length = 6, value = "", onChange }: OtpInputP
value={char} value={char}
onChange={(e) => handleChange(e.target.value, i)} onChange={(e) => handleChange(e.target.value, i)}
onKeyDown={(e) => handleKeyDown(e, i)} onKeyDown={(e) => handleKeyDown(e, i)}
className="w-14 h-14 mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30" className="size-11 sm:size-14 mx-0.5 sm:mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30"
/> />
) )
} }

Some files were not shown because too many files have changed in this diff Show More