ui and bug fixers 29/09
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
|
||||
import { ArrowLeft, Banknote, Pencil, Plus, Search, Wallet } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -12,23 +12,44 @@ import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/g
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
type AccountTypeFilter = CashBankAccountType | "Both"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size,
|
||||
// same convention as the other list pages even though this one pages client-side.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
export default function BankAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => setPage(1), [debouncedSearch, accountType])
|
||||
|
||||
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
|
||||
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
|
||||
// There's no server pagination on this endpoint (it returns the full union in one array), so
|
||||
// page/pageSize below are applied client-side over whatever this call returns.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
|
||||
@@ -40,6 +61,7 @@ export default function BankAccountsPage() {
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
@@ -49,7 +71,7 @@ export default function BankAccountsPage() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!accounts) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
if (!q) return accounts
|
||||
return accounts.filter((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
@@ -62,116 +84,201 @@ export default function BankAccountsPage() {
|
||||
(gl?.accountCode ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [accounts, search, glAccountsById])
|
||||
}, [accounts, debouncedSearch, glAccountsById])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!accounts) return null
|
||||
return {
|
||||
total: accounts.length,
|
||||
bank: accounts.filter((a) => a.accountType === CashBankAccountType.Bank).length,
|
||||
cash: accounts.filter((a) => a.accountType === CashBankAccountType.Cash).length,
|
||||
}
|
||||
}, [accounts])
|
||||
|
||||
const totalItems = filtered?.length ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
const pageItems = filtered?.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize) ?? null
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
setShowMoreLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
|
||||
{accounts && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{accounts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name, bank/type, account no. or currency…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search cash/bank accounts"
|
||||
/>
|
||||
</div>
|
||||
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
|
||||
<SelectTrigger className="h-14! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total Accounts"
|
||||
value={stats.total}
|
||||
icon={Wallet}
|
||||
active={accountType === "Both"}
|
||||
onClick={() => setAccountType("Both")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Bank Accounts"
|
||||
value={stats.bank}
|
||||
icon={Banknote}
|
||||
active={accountType === CashBankAccountType.Bank}
|
||||
onClick={() => setAccountType((t) => (t === CashBankAccountType.Bank ? "Both" : CashBankAccountType.Bank))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cash Accounts"
|
||||
value={stats.cash}
|
||||
icon={Wallet}
|
||||
active={accountType === CashBankAccountType.Cash}
|
||||
onClick={() => setAccountType((t) => (t === CashBankAccountType.Cash ? "Both" : CashBankAccountType.Cash))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search name, bank/type, account no. or currency…"
|
||||
advancedActive={accountType !== "Both"}
|
||||
onClearAdvanced={() => setAccountType("Both")}
|
||||
advanced={
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bank-filter-type">Type</FieldLabel>
|
||||
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
|
||||
<SelectTrigger id="bank-filter-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Both">All types</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Bank}>Bank</SelectItem>
|
||||
<SelectItem value={CashBankAccountType.Cash}>Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && filtered === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
{!error && pageItems === null && (
|
||||
<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) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!error && filtered !== null && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Wallet className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
|
||||
</p>
|
||||
{!error && pageItems !== null && pageItems.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No accounts match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different name, bank or account number.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && filtered !== null && filtered.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((a) => {
|
||||
{!error && pageItems !== null && pageItems.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Wallet className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No cash/bank accounts yet</p>
|
||||
<p className="text-sm text-muted-foreground">Add your first cash or bank account to get started.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Account
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && pageItems !== null && pageItems.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{pageItems.map((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
<TableRow key={`${a.accountType}-${a.accountId}`}>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div key={`${a.accountType}-${a.accountId}`} 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">{a.accountName}</p>
|
||||
<span className="text-xs text-muted-foreground">{a.accountNumber ?? "—"}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-primary/10 text-primary"
|
||||
"h-6 w-16 shrink-0 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{a.accountType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Bank / Cash type</p>
|
||||
<p className="font-medium text-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Currency</p>
|
||||
<p className="font-medium text-foreground">{a.currencyCode}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">GL account</p>
|
||||
<p className="font-medium text-foreground">{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(a.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
render={<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
@@ -180,12 +287,94 @@ export default function BankAccountsPage() {
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalItems > pageItems.length && (
|
||||
<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">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account name</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Bank / Cash type</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Account no.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">GL account</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Currency</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((a) => {
|
||||
const gl = glAccountsById.get(a.glAccountId)
|
||||
return (
|
||||
<TableRow key={`${a.accountType}-${a.accountId}`} className="align-top hover:bg-muted/20">
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-16 justify-center border-transparent text-sm",
|
||||
a.accountType === CashBankAccountType.Cash ? "bg-success/10 text-success" : "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{a.accountType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 font-semibold text-foreground">{a.accountName}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.accountNumber ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{a.currencyCode}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Editing isn't available yet — the General Ledger service has no update endpoint for
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
|
||||
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
|
||||
@@ -38,6 +39,7 @@ export default function ChequeBookDetailPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const { branchingEnabled } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
@@ -99,10 +101,12 @@ export default function ChequeBookDetailPage() {
|
||||
<p className="text-sm text-muted-foreground">Bank account</p>
|
||||
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Branch</p>
|
||||
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
|
||||
</div>
|
||||
{branchingEnabled && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Branch</p>
|
||||
<p className="text-base font-medium">{branches.find((b) => b.branchId === book.branchId)?.name ?? `#${book.branchId}`}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Range</p>
|
||||
<p className="text-base font-medium">
|
||||
|
||||
@@ -57,7 +57,10 @@ export default function NewChequeBookPage() {
|
||||
// specific branch (or any non-admin) gets it locked here too — only "All Branches" (or the
|
||||
// Branch Configuration toggle being off) leaves it as a free pick, since GL's own record
|
||||
// still needs *some* branch id regardless of our toggle.
|
||||
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : null
|
||||
// With branching off the field is hidden entirely, but GL still needs a branch id — use the
|
||||
// deployment's default branch (BR-000, the one the backend files everything under then).
|
||||
const defaultBranchId = (branches.find((b) => b.code === "BR-000") ?? branches[0])?.branchId ?? null
|
||||
const lockedBranchId = branchingEnabled ? (isAdmin ? activeBranchId : myBranchId) : defaultBranchId
|
||||
const showBranchPicker = !lockedBranchId
|
||||
|
||||
useEffect(() => {
|
||||
@@ -137,6 +140,7 @@ export default function NewChequeBookPage() {
|
||||
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
{branchingEnabled && (
|
||||
<Field data-invalid={!!errors.branchId}>
|
||||
<FieldLabel htmlFor="cb-branch">Branch</FieldLabel>
|
||||
{showBranchPicker ? (
|
||||
@@ -163,6 +167,7 @@ export default function NewChequeBookPage() {
|
||||
)}
|
||||
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={!!errors.chequeBookNo}>
|
||||
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, BookText, Plus } from "lucide-react"
|
||||
import { ArrowLeft, BookCheck, BookText, BookX, CheckCircle2, Eye, Plus, Search } from "lucide-react"
|
||||
|
||||
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
@@ -15,152 +15,425 @@ import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = ChequeBookStatus | "All"
|
||||
|
||||
interface StatusCounts {
|
||||
total: number
|
||||
active: number
|
||||
completed: number
|
||||
cancelled: number
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<ChequeBookStatus, string> = {
|
||||
[ChequeBookStatus.Active]: "bg-success/10 text-success",
|
||||
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
|
||||
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
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 ChequeBooksPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
const [books, setBooks] = useState<ChequeBook[] | null>(null)
|
||||
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
|
||||
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => setPage(1), [status, activeBranchId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
Promise.all([
|
||||
chequeBooksApi.list({
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
}),
|
||||
chequeBooksApi.list({ page, pageSize, status: status === "All" ? undefined : status, branchId }),
|
||||
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
|
||||
])
|
||||
.then(([result, banks]) => {
|
||||
if (cancelled) return
|
||||
setBooks(result.items)
|
||||
setTotalItems(result.totalCount)
|
||||
setBankAccounts(banks)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setShowMoreLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, activeBranchId, branchingEnabled])
|
||||
}, [page, pageSize, status, activeBranchId, branchingEnabled])
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
|
||||
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
|
||||
// out the other tiles.
|
||||
useEffect(() => {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
const countFor = (s?: ChequeBookStatus) =>
|
||||
chequeBooksApi
|
||||
.list({ page: 1, pageSize: 1, status: s, branchId })
|
||||
.then((res) => res.totalCount)
|
||||
.catch(() => 0)
|
||||
|
||||
Promise.all([
|
||||
countFor(undefined),
|
||||
countFor(ChequeBookStatus.Active),
|
||||
countFor(ChequeBookStatus.Completed),
|
||||
countFor(ChequeBookStatus.Cancelled),
|
||||
]).then(([total, active, completed, cancelled]) => setStatusCounts({ total, active, completed, cancelled }))
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
|
||||
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
|
||||
|
||||
// The API has no `q` param, so search narrows whatever page is currently loaded — same
|
||||
// pattern as the Sales Invoices list.
|
||||
const visibleBooks = useMemo(() => {
|
||||
if (!books) return null
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
if (!q) return books
|
||||
return books.filter((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
const branch = branchesById.get(b.branchId)
|
||||
return (
|
||||
b.chequeBookNo.toLowerCase().includes(q) ||
|
||||
(bank?.accountName ?? "").toLowerCase().includes(q) ||
|
||||
(branch?.name ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [books, debouncedSearch, bankAccountsById, branchesById])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
const hasFilters = debouncedSearch.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
|
||||
{statusCounts && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{statusCounts.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Cheque books issued from this company's own supply.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Cheque Book
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusCounts ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total Cheque Books"
|
||||
value={statusCounts.total}
|
||||
icon={BookText}
|
||||
active={status === "All"}
|
||||
onClick={() => setStatus("All")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active"
|
||||
value={statusCounts.active}
|
||||
icon={CheckCircle2}
|
||||
active={status === ChequeBookStatus.Active}
|
||||
onClick={() => setStatus((s) => (s === ChequeBookStatus.Active ? "All" : ChequeBookStatus.Active))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Completed"
|
||||
value={statusCounts.completed}
|
||||
icon={BookCheck}
|
||||
active={status === ChequeBookStatus.Completed}
|
||||
onClick={() => setStatus((s) => (s === ChequeBookStatus.Completed ? "All" : ChequeBookStatus.Completed))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cancelled"
|
||||
value={statusCounts.cancelled}
|
||||
icon={BookX}
|
||||
active={status === ChequeBookStatus.Cancelled}
|
||||
onClick={() => setStatus((s) => (s === ChequeBookStatus.Cancelled ? "All" : ChequeBookStatus.Cancelled))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search cheque book no., bank or branch…"
|
||||
advancedActive={status !== "All"}
|
||||
onClearAdvanced={() => setStatus("All")}
|
||||
advanced={
|
||||
<Field>
|
||||
<FieldLabel htmlFor="chq-filter-status">Status</FieldLabel>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger id="chq-filter-status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All statuses</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Active}>Active</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Completed}>Completed</SelectItem>
|
||||
<SelectItem value={ChequeBookStatus.Cancelled}>Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && books === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
{!error && visibleBooks === null && (
|
||||
<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) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!error && books !== null && books.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BookText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No cheque books yet.</p>
|
||||
{!error && visibleBooks !== null && visibleBooks.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No cheque books match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different cheque book no., bank or branch.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && books !== null && books.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{books.map((b) => {
|
||||
{!error && visibleBooks !== null && visibleBooks.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<BookText className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">{hasFilters ? "No cheque books match your filters." : "No cheque books yet."}</p>
|
||||
{!hasFilters && <p className="text-sm text-muted-foreground">Add your first cheque book to get started.</p>}
|
||||
</div>
|
||||
{!hasFilters && (
|
||||
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Cheque Book
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleBooks !== null && visibleBooks.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visibleBooks.map((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
return (
|
||||
<TableRow
|
||||
<div
|
||||
key={b.chequeBookNo}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{b.startChequeNo} – {b.endChequeNo}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="truncate font-semibold text-foreground">{b.chequeBookNo}</p>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Bank account</p>
|
||||
<p className="font-medium text-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</p>
|
||||
</div>
|
||||
{branchingEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<p className="font-medium text-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Range</p>
|
||||
<p className="font-medium text-foreground">{b.startChequeNo} – {b.endChequeNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Leaves</p>
|
||||
<p className="font-medium text-foreground">{b.totalLeaves}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Received</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(b.receivedDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${b.chequeBookNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalItems > visibleBooks.length && (
|
||||
<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">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Cheque book no.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Bank account</TableHead>
|
||||
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-11 px-3 text-sm">Range</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Leaves</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleBooks.map((b) => {
|
||||
const bank = bankAccountsById.get(b.bankAccountId)
|
||||
return (
|
||||
<TableRow
|
||||
key={b.chequeBookNo}
|
||||
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/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||
className="font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{b.chequeBookNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{bank ? bank.accountName : `#${b.bankAccountId}`}</TableCell>
|
||||
{branchingEnabled && (
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.startChequeNo} – {b.endChequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{b.totalLeaves}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[b.status])}>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${b.chequeBookNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Inbox, Plus } from "lucide-react"
|
||||
import { ArrowLeft, Banknote, CheckCircle2, Eye, Inbox, Plus, Search, Undo2, Wallet } from "lucide-react"
|
||||
|
||||
import { receivedChequesApi } from "@/lib/api/general-ledger"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
@@ -14,14 +14,27 @@ import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
|
||||
|
||||
type StatusFilter = ReceivedChequeStatus | "All"
|
||||
|
||||
interface StatusCounts {
|
||||
total: number
|
||||
received: number
|
||||
deposited: number
|
||||
cleared: number
|
||||
returned: number
|
||||
cancelled: number
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
|
||||
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
|
||||
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
|
||||
@@ -30,12 +43,27 @@ const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
|
||||
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
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 ReceivedChequesPage() {
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
|
||||
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
@@ -43,124 +71,385 @@ export default function ReceivedChequesPage() {
|
||||
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => setPage(1), [status, activeBranchId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
receivedChequesApi
|
||||
.list({
|
||||
page,
|
||||
pageSize,
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) setCheques(res.items)
|
||||
if (cancelled) return
|
||||
setCheques(res.items)
|
||||
setTotalItems(res.totalCount)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(errorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setShowMoreLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [status, activeBranchId, branchingEnabled])
|
||||
}, [page, pageSize, status, activeBranchId, branchingEnabled])
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
|
||||
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
|
||||
// out the other tiles.
|
||||
useEffect(() => {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
const countFor = (s?: ReceivedChequeStatus) =>
|
||||
receivedChequesApi
|
||||
.list({ page: 1, pageSize: 1, status: s, branchId })
|
||||
.then((res) => res.totalCount)
|
||||
.catch(() => 0)
|
||||
|
||||
Promise.all([
|
||||
countFor(undefined),
|
||||
countFor(ReceivedChequeStatus.Received),
|
||||
countFor(ReceivedChequeStatus.Deposited),
|
||||
countFor(ReceivedChequeStatus.Cleared),
|
||||
countFor(ReceivedChequeStatus.Returned),
|
||||
countFor(ReceivedChequeStatus.Cancelled),
|
||||
]).then(([total, received, deposited, cleared, returned, cancelled]) =>
|
||||
setStatusCounts({ total, received, deposited, cleared, returned, cancelled })
|
||||
)
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
const branchesById = useMemo(() => new Map(branches.map((b) => [b.branchId, b])), [branches])
|
||||
|
||||
// The API has no `q` param, so search narrows whatever page is currently loaded — same
|
||||
// pattern as the Sales Invoices list.
|
||||
const visibleCheques = useMemo(() => {
|
||||
if (!cheques) return null
|
||||
const q = debouncedSearch.toLowerCase()
|
||||
if (!q) return cheques
|
||||
return cheques.filter((c) => {
|
||||
const branch = c.branchId ? branchesById.get(c.branchId) : null
|
||||
return (
|
||||
c.chequeNo.toLowerCase().includes(q) ||
|
||||
c.receivedFromName.toLowerCase().includes(q) ||
|
||||
(branch?.name ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [cheques, debouncedSearch, branchesById])
|
||||
|
||||
function handleUpdated(updated: ReceivedCheque) {
|
||||
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
|
||||
setSelected(updated)
|
||||
}
|
||||
|
||||
function openDialog(c: ReceivedCheque) {
|
||||
setSelected(c)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
const hasFilters = debouncedSearch.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">
|
||||
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
|
||||
{statusCounts && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{statusCounts.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Received Cheque
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusCounts ? (
|
||||
<>
|
||||
<StatCard
|
||||
label="Total Received"
|
||||
value={statusCounts.total}
|
||||
icon={Inbox}
|
||||
active={status === "All"}
|
||||
onClick={() => setStatus("All")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Received"
|
||||
value={statusCounts.received}
|
||||
icon={Wallet}
|
||||
active={status === ReceivedChequeStatus.Received}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Received ? "All" : ReceivedChequeStatus.Received))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Deposited"
|
||||
value={statusCounts.deposited}
|
||||
icon={Banknote}
|
||||
active={status === ReceivedChequeStatus.Deposited}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Deposited ? "All" : ReceivedChequeStatus.Deposited))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cleared"
|
||||
value={statusCounts.cleared}
|
||||
icon={CheckCircle2}
|
||||
active={status === ReceivedChequeStatus.Cleared}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Cleared ? "All" : ReceivedChequeStatus.Cleared))}
|
||||
/>
|
||||
<StatCard
|
||||
label="Returned"
|
||||
value={statusCounts.returned}
|
||||
icon={Undo2}
|
||||
active={status === ReceivedChequeStatus.Returned}
|
||||
onClick={() => setStatus((s) => (s === ReceivedChequeStatus.Returned ? "All" : ReceivedChequeStatus.Returned))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder={branchingEnabled ? "Search cheque no., received from or branch…" : "Search cheque no. or received from…"}
|
||||
advancedActive={status !== "All"}
|
||||
onClearAdvanced={() => setStatus("All")}
|
||||
advanced={
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-filter-status">Status</FieldLabel>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger id="rc-filter-status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All statuses</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Received}>Received</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Deposited}>Deposited</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cleared}>Cleared</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Returned}>Returned</SelectItem>
|
||||
<SelectItem value={ReceivedChequeStatus.Cancelled}>Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && cheques === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
{!error && visibleCheques === null && (
|
||||
<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) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!error && cheques !== null && cheques.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Inbox className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No received cheques yet.</p>
|
||||
{!error && visibleCheques !== null && visibleCheques.length === 0 && debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No cheques match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different cheque no., name or branch.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && cheques !== null && cheques.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cheques.map((c) => (
|
||||
<TableRow
|
||||
{!error && visibleCheques !== null && visibleCheques.length === 0 && !debouncedSearch && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Inbox className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">{hasFilters ? "No received cheques match your filters." : "No received cheques yet."}</p>
|
||||
{!hasFilters && <p className="text-sm text-muted-foreground">Record your first received cheque to get started.</p>}
|
||||
</div>
|
||||
{!hasFilters && (
|
||||
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants())}>
|
||||
<Plus className="size-4" />
|
||||
New Received Cheque
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visibleCheques !== null && visibleCheques.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visibleCheques.map((c) => (
|
||||
<div
|
||||
key={c.receivedChequeId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelected(c)
|
||||
setDialogOpen(true)
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
openDialog(c)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") openDialog(c)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatReportDate(c.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="truncate font-semibold text-foreground">{c.chequeNo}</p>
|
||||
<Badge variant="outline" className={cn("h-6 w-fit shrink-0 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Received from</p>
|
||||
<p className="font-medium text-foreground">{c.receivedFromName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Type</p>
|
||||
<p className="font-medium text-foreground">{c.receivedFromType}</p>
|
||||
</div>
|
||||
{branchingEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<p className="font-medium text-foreground">{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Amount</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{formatAmount(c.amount)}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Received</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(c.receivedDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t pt-3" data-no-row-nav>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View cheque ${c.chequeNo}`}
|
||||
title="View"
|
||||
onClick={() => openDialog(c)}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalItems > visibleCheques.length && (
|
||||
<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">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-11 px-3 text-sm">Cheque no.</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Received from</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Type</TableHead>
|
||||
{branchingEnabled && <TableHead className="h-11 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-11 px-3 text-sm text-right">Amount</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleCheques.map((c) => (
|
||||
<TableRow
|
||||
key={c.receivedChequeId}
|
||||
className="align-top cursor-pointer hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
openDialog(c)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-4 font-semibold text-foreground">{c.chequeNo}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromName}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{c.receivedFromType}</TableCell>
|
||||
{branchingEnabled && (
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-4 text-right text-sm tabular-nums text-muted-foreground">{formatAmount(c.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<Badge variant="outline" className={cn("h-5 w-fit justify-center border-transparent text-xs", STATUS_BADGE[c.status])}>
|
||||
{c.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View cheque ${c.chequeNo}`}
|
||||
title="View"
|
||||
onClick={() => openDialog(c)}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={{ page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)) }}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Eye,
|
||||
HelpCircle,
|
||||
Inbox,
|
||||
Plus,
|
||||
@@ -19,7 +20,7 @@ import { SupportTicketSummary } from "@/types/support"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
@@ -63,6 +64,7 @@ export default function HelpPage() {
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<SupportTicketSummary["status"] | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [subject, setSubject] = useState("")
|
||||
@@ -87,6 +89,12 @@ export default function HelpPage() {
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => setPage(1), [statusFilter])
|
||||
|
||||
function toggleStatusFilter(status: SupportTicketSummary["status"]) {
|
||||
setStatusFilter((s) => (s === status ? null : status))
|
||||
}
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!tickets) return null
|
||||
return {
|
||||
@@ -100,11 +108,12 @@ export default function HelpPage() {
|
||||
const filtered = useMemo(() => {
|
||||
if (!tickets) return []
|
||||
const term = debouncedSearch.toLowerCase()
|
||||
if (!term) return tickets
|
||||
return tickets.filter(
|
||||
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
|
||||
(t) =>
|
||||
(!statusFilter || t.status === statusFilter) &&
|
||||
(!term || t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term))
|
||||
)
|
||||
}, [tickets, debouncedSearch])
|
||||
}, [tickets, debouncedSearch, statusFilter])
|
||||
|
||||
const totalItems = filtered.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
||||
@@ -207,16 +216,40 @@ export default function HelpPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
|
||||
<StatCard label="Open" value={stats.open} icon={Inbox} />
|
||||
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
|
||||
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
|
||||
<StatCard
|
||||
label="Total tickets"
|
||||
value={stats.total}
|
||||
icon={HelpCircle}
|
||||
active={statusFilter === null}
|
||||
onClick={() => setStatusFilter(null)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Open"
|
||||
value={stats.open}
|
||||
icon={Inbox}
|
||||
active={statusFilter === "Open"}
|
||||
onClick={() => toggleStatusFilter("Open")}
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={stats.inProgress}
|
||||
icon={Clock}
|
||||
active={statusFilter === "InProgress"}
|
||||
onClick={() => toggleStatusFilter("InProgress")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Resolved"
|
||||
value={stats.resolved}
|
||||
icon={CheckCircle2}
|
||||
active={statusFilter === "Resolved"}
|
||||
onClick={() => toggleStatusFilter("Resolved")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -264,15 +297,17 @@ export default function HelpPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && tickets !== null && pageItems.length === 0 && debouncedSearch && (
|
||||
{!error && tickets !== null && pageItems.length === 0 && tickets.length > 0 && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
|
||||
<Search className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-foreground">No tickets match "{debouncedSearch}"</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different subject or name.</p>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{debouncedSearch ? <>No tickets match "{debouncedSearch}"</> : `No ${statusLabels[statusFilter ?? ""] ?? ""} tickets`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Try a different subject/name, or clear the status filter.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
|
||||
Clear search
|
||||
<Button variant="outline" size="sm" onClick={() => { setSearch(""); setStatusFilter(null) }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -294,11 +329,7 @@ export default function HelpPage() {
|
||||
{/* 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 key={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])}>
|
||||
@@ -323,7 +354,13 @@ export default function HelpPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center justify-end border-t pt-3">
|
||||
<Link href={`/dashboard/help/tickets/${t.ticketId}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Eye className="size-4" />
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{totalItems > pageItems.length && (
|
||||
@@ -344,6 +381,7 @@ export default function HelpPage() {
|
||||
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
|
||||
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
|
||||
<TableHead className="h-11 w-20 px-3 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -371,6 +409,18 @@ export default function HelpPage() {
|
||||
day: "numeric",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/help/tickets/${t.ticketId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ticket ${t.subject}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -167,13 +167,31 @@ export default function EmployeesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total employees" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<StatCard
|
||||
label="Total employees"
|
||||
value={stats.total}
|
||||
icon={UsersIcon}
|
||||
active={statusFilter === undefined}
|
||||
onClick={() => setStatusFilter(undefined)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
icon={UserCheck}
|
||||
active={statusFilter === "Active"}
|
||||
onClick={() => setStatusFilter((s) => (s === "Active" ? undefined : "Active"))}
|
||||
/>
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Terminated" value={stats.terminated} icon={UserMinus} />
|
||||
<StatCard
|
||||
label="Terminated"
|
||||
value={stats.terminated}
|
||||
icon={UserMinus}
|
||||
active={statusFilter === "Terminated"}
|
||||
onClick={() => setStatusFilter((s) => (s === "Terminated" ? undefined : "Terminated"))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckCircle2, Clock, Plus, Search, X, XCircle } from "lucide-react"
|
||||
import { CheckCircle2, Clock, PenLine, Plus, Search, X, XCircle } from "lucide-react"
|
||||
|
||||
import { employeesApi } from "@/lib/api/employees"
|
||||
import { leaveTypesApi } from "@/lib/api/hrm-masters"
|
||||
@@ -10,7 +10,6 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -41,8 +40,8 @@ const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
|
||||
}
|
||||
|
||||
export default function LeaveRequestsPage() {
|
||||
const stats = useDashboardStats()
|
||||
const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
|
||||
const [statusCounts, setStatusCounts] = useState<Record<LeaveRequestStatus, number> | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -91,6 +90,22 @@ export default function LeaveRequestsPage() {
|
||||
|
||||
useEffect(load, [page, pageSize, statusFilter, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status. Each call falls back
|
||||
// to 0 on its own so one bad request doesn't blank out the other tiles.
|
||||
function loadCounts() {
|
||||
Promise.all(
|
||||
STATUSES.map((s) =>
|
||||
leaveRequestsApi
|
||||
.list({ page: 1, pageSize: 1, status: s })
|
||||
.then((res) => res.pagination.totalItems)
|
||||
.catch(() => 0)
|
||||
)
|
||||
).then((values) => {
|
||||
setStatusCounts(Object.fromEntries(STATUSES.map((s, i) => [s, values[i]])) as Record<LeaveRequestStatus, number>)
|
||||
})
|
||||
}
|
||||
useEffect(loadCounts, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
@@ -113,7 +128,7 @@ export default function LeaveRequestsPage() {
|
||||
toast.success("Leave request submitted")
|
||||
setOpen(false)
|
||||
setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("")
|
||||
load()
|
||||
load(); loadCounts()
|
||||
} catch (err) {
|
||||
toast.error("Could not submit leave request", errorMessage(err))
|
||||
} finally {
|
||||
@@ -126,7 +141,7 @@ export default function LeaveRequestsPage() {
|
||||
try {
|
||||
await leaveRequestsApi.approve(id)
|
||||
toast.success("Leave approved")
|
||||
load()
|
||||
load(); loadCounts()
|
||||
} catch (err) {
|
||||
toast.error("Could not approve", errorMessage(err))
|
||||
} finally {
|
||||
@@ -149,7 +164,7 @@ export default function LeaveRequestsPage() {
|
||||
await leaveRequestsApi.reject(rejectTarget.leaveRequestId, rejectReason.trim())
|
||||
toast.success("Leave rejected")
|
||||
setRejectTarget(null)
|
||||
load()
|
||||
load(); loadCounts()
|
||||
} catch (err) {
|
||||
toast.error("Could not reject", errorMessage(err))
|
||||
} finally {
|
||||
@@ -238,7 +253,49 @@ export default function LeaveRequestsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
|
||||
<KpiRow
|
||||
tiles={
|
||||
statusCounts
|
||||
? [
|
||||
{
|
||||
label: "Draft",
|
||||
value: statusCounts.Draft,
|
||||
icon: PenLine,
|
||||
active: statusFilter === "Draft",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Draft" ? ALL_STATUS : "Draft")) },
|
||||
},
|
||||
{
|
||||
label: "Pending Leave",
|
||||
value: statusCounts.Submitted,
|
||||
icon: Clock,
|
||||
active: statusFilter === "Submitted",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Submitted" ? ALL_STATUS : "Submitted")) },
|
||||
},
|
||||
{
|
||||
label: "Approved",
|
||||
value: statusCounts.Approved,
|
||||
icon: CheckCircle2,
|
||||
active: statusFilter === "Approved",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Approved" ? ALL_STATUS : "Approved")) },
|
||||
},
|
||||
{
|
||||
label: "Rejected",
|
||||
value: statusCounts.Rejected,
|
||||
icon: XCircle,
|
||||
active: statusFilter === "Rejected",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Rejected" ? ALL_STATUS : "Rejected")) },
|
||||
},
|
||||
{
|
||||
label: "Cancelled",
|
||||
value: statusCounts.Cancelled,
|
||||
icon: X,
|
||||
active: statusFilter === "Cancelled",
|
||||
onClick: () => { setPage(1); setStatusFilter((s) => (s === "Cancelled" ? ALL_STATUS : "Cancelled")) },
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function PayrollRunDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
|
||||
<StatChip icon={Wallet} label="Gross" value={money(run.totalGross)} />
|
||||
<StatChip icon={BadgeDollarSign} label="Net" value={money(run.totalNet)} />
|
||||
<StatChip icon={Users} label="Employees" value={String(run.employeeCount)} />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Eye, Plus } from "lucide-react"
|
||||
|
||||
import { payrollRunsApi } from "@/lib/api/payroll"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
@@ -10,7 +11,7 @@ import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { PayrollRun, PayrollRunStatus } from "@/types/hrm"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
@@ -210,6 +211,7 @@ export default function PayrollRunsPage() {
|
||||
<TableHead className="h-11 px-4 text-sm">Gross</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 w-24 px-4 text-center text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -229,6 +231,18 @@ export default function PayrollRunsPage() {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/hrm/payroll/${r.payrollRunId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label={`View payroll run ${r.docNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
|
||||
const NONE = "__none__"
|
||||
@@ -31,6 +32,7 @@ export default function DepartmentsPage() {
|
||||
const [items, setItems] = useState<Department[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const { branchingEnabled } = useAuth()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
@@ -227,18 +229,20 @@ export default function DepartmentsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
|
||||
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={branchId} onValueChange={(v) => setBranchId(v ?? NONE)}>
|
||||
<SelectTrigger id="d-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<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-32" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
|
||||
@@ -282,18 +286,20 @@ export default function DepartmentsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
||||
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{branchingEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="d-edit-branch">Branch (optional)</FieldLabel>
|
||||
<Select value={editBranchId} onValueChange={(v) => setEditBranchId(v ?? NONE)} disabled={editLoading}>
|
||||
<SelectTrigger id="d-edit-branch"><SelectValue placeholder="None" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{branches.map((b) => (
|
||||
<SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<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-32" onClick={() => setEditItem(null)} disabled={editSubmitting}>Cancel</Button>
|
||||
|
||||
@@ -129,30 +129,30 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{loaded ? (
|
||||
<>
|
||||
<Link href="/dashboard/stock/reorder-alerts">
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
||||
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/enquiry">
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/receiving/grn">
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/requisitions">
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
|
||||
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/stock/counts">
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
|
||||
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} size="sm" />
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/rfqs">
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
|
||||
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} size="sm" />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20 w-52 rounded-2xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -55,7 +56,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const poId = Number(params.id)
|
||||
const { isAdmin } = useAuth()
|
||||
const { isAdmin, userId } = useAuth()
|
||||
|
||||
const [po, setPo] = useState<PurchaseOrder | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
@@ -121,6 +122,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poId])
|
||||
|
||||
// Searchable item picker options — the label is what the combobox filters on.
|
||||
const itemOptions = items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
@@ -307,6 +311,19 @@ export default function PurchaseOrderDetailPage() {
|
||||
// a Draft is deleted instead, and closed/cancelled POs are terminal.
|
||||
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
|
||||
|
||||
// Mirrors PurchaseOrderService.LoadForApprovalDecisionAsync: only an Admin who didn't
|
||||
// create the PO may approve/reject it. Explain why instead of silently hiding the buttons.
|
||||
const isOwnPo = userId !== null && po.createdBy === userId
|
||||
const approvalBlockedReason =
|
||||
po.status !== "PendingApproval"
|
||||
? null
|
||||
: !isAdmin
|
||||
? "This purchase order is awaiting approval. Only an Admin can approve or reject it."
|
||||
: isOwnPo
|
||||
? "You created this purchase order, so you can't approve or reject it yourself. Another Admin must review it."
|
||||
: null
|
||||
const canDecide = po.status === "PendingApproval" && approvalBlockedReason === null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -346,7 +363,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
{po.status === "PendingApproval" && isAdmin && (
|
||||
{canDecide && (
|
||||
<>
|
||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
|
||||
<Check className="size-5" />
|
||||
@@ -374,6 +391,13 @@ export default function PurchaseOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{approvalBlockedReason && (
|
||||
<div role="status" className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
|
||||
<p>{approvalBlockedReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCancelForm && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
|
||||
<p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p>
|
||||
@@ -485,18 +509,16 @@ export default function PurchaseOrderDetailPage() {
|
||||
<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>
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</div>
|
||||
<Button
|
||||
@@ -593,7 +615,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
@@ -623,18 +645,17 @@ export default function PurchaseOrderDetailPage() {
|
||||
return (
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<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>
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
popupClassName="min-w-96"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -69,7 +70,9 @@ function NewPurchaseOrderContent() {
|
||||
const requisitionId = Number(searchParams.get("requisitionId")) || null
|
||||
const rfqId = Number(searchParams.get("rfqId")) || null
|
||||
const rfqVendorId = Number(searchParams.get("vendorId")) || null
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
// The PO's branch follows the purchase-order sub-toggle — off means no branch is asked for
|
||||
// and the server files the PO on the default branch.
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, purchaseOrderBranchingEnabled: branchingEnabled, warehouseBranchingEnabled } = useAuth()
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||
@@ -92,6 +95,8 @@ function NewPurchaseOrderContent() {
|
||||
const [vName, setVName] = useState("")
|
||||
const [vTerms, setVTerms] = useState("")
|
||||
const [vTaxReg, setVTaxReg] = useState("")
|
||||
const [vAddress, setVAddress] = useState("")
|
||||
const [vPhone, setVPhone] = useState("")
|
||||
const [vCurrency, setVCurrency] = useState("LKR")
|
||||
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||
const [vSubmitting, setVSubmitting] = useState(false)
|
||||
@@ -131,6 +136,8 @@ function NewPurchaseOrderContent() {
|
||||
setVName("")
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVAddress("")
|
||||
setVPhone("")
|
||||
setVCurrency("LKR")
|
||||
setVErrors({})
|
||||
}
|
||||
@@ -149,6 +156,8 @@ function NewPurchaseOrderContent() {
|
||||
name: vName,
|
||||
terms: vTerms || null,
|
||||
taxReg: vTaxReg || null,
|
||||
address: vAddress || null,
|
||||
phone: vPhone || null,
|
||||
currency: vCurrency,
|
||||
})
|
||||
await loadVendors()
|
||||
@@ -232,6 +241,14 @@ function NewPurchaseOrderContent() {
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
|
||||
// Line warehouses must sit in the PO's branch only when warehouses are divided by branch
|
||||
// too (mirrors PurchaseOrderService.EnsureLinesInBranchAsync); otherwise every warehouse
|
||||
// is shared and all of them are offered.
|
||||
const lineWarehouses =
|
||||
branchingEnabled && warehouseBranchingEnabled && effectiveBranchId
|
||||
? (warehouses ?? []).filter((w) => w.branchId === effectiveBranchId)
|
||||
: (warehouses ?? [])
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
@@ -240,6 +257,9 @@ function NewPurchaseOrderContent() {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
// Searchable item picker options — the label is what the combobox filters on.
|
||||
const itemOptions = (items ?? []).map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` }))
|
||||
|
||||
function itemFor(itemId: number | null) {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
@@ -377,6 +397,14 @@ function NewPurchaseOrderContent() {
|
||||
<FieldLabel htmlFor="po-v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="po-v-taxreg" value={vTaxReg} onChange={(e) => setVTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-address">Address (optional)</FieldLabel>
|
||||
<Input id="po-v-address" value={vAddress} onChange={(e) => setVAddress(e.target.value)} placeholder="No. 12, Main Street, Colombo 03" maxLength={500} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="po-v-phone" type="tel" value={vPhone} onChange={(e) => setVPhone(e.target.value)} placeholder="+94 77 123 4567" maxLength={30} />
|
||||
</Field>
|
||||
<Field data-invalid={!!vErrors.currency}>
|
||||
<FieldLabel htmlFor="po-v-currency">Currency</FieldLabel>
|
||||
<Input id="po-v-currency" value={vCurrency} onChange={(e) => setVCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!vErrors.currency} />
|
||||
@@ -468,22 +496,16 @@ function NewPurchaseOrderContent() {
|
||||
</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>
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
@@ -514,7 +536,7 @@ function NewPurchaseOrderContent() {
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
{lineWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
@@ -565,7 +587,7 @@ function NewPurchaseOrderContent() {
|
||||
<Table className="table-fixed text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-80 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
@@ -584,22 +606,17 @@ function NewPurchaseOrderContent() {
|
||||
<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>
|
||||
<Combobox<number>
|
||||
items={itemOptions}
|
||||
value={line.itemId}
|
||||
onValueChange={(v) => updateLine(line.key, { itemId: v })}
|
||||
placeholder="Search item by SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="h-11 text-base"
|
||||
popupClassName="min-w-96"
|
||||
openDownward
|
||||
aria-invalid={!!errors.itemId}
|
||||
/>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
@@ -615,7 +632,7 @@ function NewPurchaseOrderContent() {
|
||||
<SelectValue placeholder="Warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(warehouses ?? []).map((w) => (
|
||||
{lineWarehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code}
|
||||
</SelectItem>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Clock, Eye, PackageCheck, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -38,7 +38,9 @@ const MOBILE_PAGE_SIZE_STEP = 10
|
||||
export default function PurchaseOrdersListPage() {
|
||||
const router = useRouter()
|
||||
const stats = useDashboardStats()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
// Every branch picker/column/filter on this screen follows the purchase-order sub-toggle,
|
||||
// not the global one — off means POs are one shared pool across branches.
|
||||
const { isAdmin, activeBranchId, purchaseOrderBranchingEnabled: branchingEnabled } = useAuth()
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
@@ -90,8 +92,12 @@ export default function PurchaseOrdersListPage() {
|
||||
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [isAdmin, branchingEnabled])
|
||||
|
||||
function vendorOf(vendorId: number) {
|
||||
return vendors.find((v) => v.vendorId === vendorId)
|
||||
}
|
||||
|
||||
function vendorCode(vendorId: number) {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
return vendorOf(vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
function branchName(branchId: number) {
|
||||
@@ -132,7 +138,14 @@ export default function PurchaseOrdersListPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
|
||||
{pagination && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pagination.totalItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it's issued (FR-PROC-03..05).</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
@@ -150,7 +163,28 @@ export default function PurchaseOrdersListPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
|
||||
<KpiRow
|
||||
tiles={
|
||||
stats
|
||||
? [
|
||||
{
|
||||
label: "Pending Approval",
|
||||
value: stats.pendingApprovalPurchaseOrders,
|
||||
icon: Clock,
|
||||
active: status === "PendingApproval",
|
||||
onClick: () => setStatus((s) => (s === "PendingApproval" ? "All" : "PendingApproval")),
|
||||
},
|
||||
{
|
||||
label: "Fully Received",
|
||||
value: stats.fullyReceivedPurchaseOrders,
|
||||
icon: PackageCheck,
|
||||
active: status === "FullyReceived",
|
||||
onClick: () => setStatus((s) => (s === "FullyReceived" ? "All" : "FullyReceived")),
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
@@ -237,6 +271,9 @@ export default function PurchaseOrdersListPage() {
|
||||
<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>
|
||||
{vendorOf(po.vendorId)?.name && (
|
||||
<p className="truncate text-sm text-foreground">{vendorOf(po.vendorId)?.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<PoStatusBadge status={po.status} />
|
||||
</div>
|
||||
@@ -250,6 +287,17 @@ export default function PurchaseOrdersListPage() {
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Details</p>
|
||||
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
|
||||
<>
|
||||
{vendorOf(po.vendorId)?.address && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.address}</p>}
|
||||
{vendorOf(po.vendorId)?.phone && <p className="font-medium text-foreground">{vendorOf(po.vendorId)?.phone}</p>}
|
||||
</>
|
||||
) : (
|
||||
<p className="font-medium text-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
@@ -339,6 +387,7 @@ export default function PurchaseOrdersListPage() {
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-48">Doc No</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Grand total</TableHead>
|
||||
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
|
||||
@@ -363,7 +412,22 @@ export default function PurchaseOrdersListPage() {
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
|
||||
{vendorOf(po.vendorId)?.name && (
|
||||
<p className="text-sm font-medium text-foreground">{vendorOf(po.vendorId)?.name}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 whitespace-normal text-sm text-muted-foreground">
|
||||
{vendorOf(po.vendorId)?.address || vendorOf(po.vendorId)?.phone ? (
|
||||
<>
|
||||
{vendorOf(po.vendorId)?.address && <p>{vendorOf(po.vendorId)?.address}</p>}
|
||||
{vendorOf(po.vendorId)?.phone && <p className="whitespace-nowrap">{vendorOf(po.vendorId)?.phone}</p>}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
@@ -373,7 +437,7 @@ export default function PurchaseOrdersListPage() {
|
||||
<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 className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
|
||||
@@ -78,7 +78,21 @@ export default function RequisitionsListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
|
||||
<KpiRow
|
||||
tiles={
|
||||
stats
|
||||
? [
|
||||
{
|
||||
label: "Open Requisitions",
|
||||
value: stats.openRequisitions,
|
||||
icon: Send,
|
||||
active: status === "Submitted",
|
||||
onClick: () => setStatus((s) => (s === "Submitted" ? "All" : "Submitted")),
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
|
||||
@@ -381,7 +381,7 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(2,minmax(0,16rem))] lg:grid-cols-[repeat(4,minmax(0,16rem))]">
|
||||
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
|
||||
<StatChip
|
||||
icon={BadgeDollarSign}
|
||||
|
||||
@@ -502,7 +502,7 @@ export default function NewItemPage() {
|
||||
{!loading && baseUomId === null && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
|
||||
No unit of measure exists yet. Items need a base UOM —{" "}
|
||||
<Link href="/dashboard/products/uoms" className="font-semibold underline">
|
||||
<Link href="/dashboard/products/brands?tab=uom" className="font-semibold underline">
|
||||
create one first
|
||||
</Link>
|
||||
.
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function ItemsPage() {
|
||||
<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 className="flex flex-wrap items-center gap-2">
|
||||
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Link href="/dashboard/products/brands?tab=uom" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Ruler className="size-5" />
|
||||
UOMs
|
||||
</Link>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
|
||||
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ReceiptText, ShieldAlert, XCircle } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -24,6 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||
import { GrnPrintDialog } from "@/components/receiving/GrnPrint"
|
||||
|
||||
export default function GrnDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
@@ -40,6 +41,7 @@ export default function GrnDetailPage() {
|
||||
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
|
||||
|
||||
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
|
||||
const [printOpen, setPrintOpen] = useState(false)
|
||||
|
||||
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
|
||||
const idempotencyKey = useRef(crypto.randomUUID())
|
||||
@@ -155,6 +157,10 @@ export default function GrnDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={() => setPrintOpen(true)}>
|
||||
<ReceiptText className="size-5" />
|
||||
GRN note
|
||||
</Button>
|
||||
{grn.status !== "Draft" && (
|
||||
<Link
|
||||
href="/dashboard/ledgers/general-ledger"
|
||||
@@ -174,6 +180,8 @@ export default function GrnDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GrnPrintDialog grnId={grn.grnId} docNo={grn.docNo} open={printOpen} onOpenChange={setPrintOpen} />
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
@@ -239,14 +239,16 @@ export default function NewGrnPage() {
|
||||
itemsApi.list({ pageSize: 200, status: "Active" }),
|
||||
uomsApi.list(),
|
||||
vendorsApi.list({ pageSize: 200, status: "Active" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
// Only receivable POs, filtered server-side so older open POs aren't cut off by the page cap.
|
||||
purchaseOrdersApi.list({ pageSize: 200, status: "Approved" }),
|
||||
purchaseOrdersApi.list({ pageSize: 200, status: "PartiallyReceived" }),
|
||||
])
|
||||
.then(([wh, it, uo, ve, po]) => {
|
||||
.then(([wh, it, uo, ve, approved, partial]) => {
|
||||
setWarehouses(wh.items)
|
||||
setItems(it.items)
|
||||
setUoms(uo.items)
|
||||
setVendors(ve.items)
|
||||
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
|
||||
setPurchaseOrders([...approved.items, ...partial.items].sort((a, b) => b.poId - a.poId))
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [])
|
||||
@@ -631,9 +633,11 @@ export default function NewGrnPage() {
|
||||
{mode === "po" && (
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label className="text-base">Purchase order</Label>
|
||||
<Select<number | null> value={poId} onValueChange={(v) => handleSelectPo(v)}>
|
||||
<Select<number | null> value={poId} onValueChange={(v) => handleSelectPo(v)} disabled={purchaseOrders?.length === 0}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select an approved/partially received PO" />
|
||||
<SelectValue
|
||||
placeholder={purchaseOrders?.length === 0 ? "No purchase orders ready to receive" : "Select an approved/partially received PO"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(purchaseOrders ?? []).map((p) => (
|
||||
@@ -643,6 +647,15 @@ export default function NewGrnPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{purchaseOrders?.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only approved or partially received POs can be received.{" "}
|
||||
<Link href="/dashboard/procurement/purchase-orders" className="font-medium text-primary hover:underline">
|
||||
Approve a purchase order
|
||||
</Link>{" "}
|
||||
first, or switch to Direct receipt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
|
||||
import { CheckCircle2, Clock, Eye, ListChecks, PackageSearch, Plus, ReceiptText, Search } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
@@ -14,7 +13,6 @@ import { Vendor, Warehouse } from "@/types/master-data"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { KpiRow } from "@/components/ui/kpi-row"
|
||||
@@ -31,6 +29,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { GrnPrintDialog } from "@/components/receiving/GrnPrint"
|
||||
|
||||
type StatusFilter = GrnStatus | "All"
|
||||
|
||||
@@ -39,12 +38,17 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
interface StatusCounts {
|
||||
pending: number
|
||||
confirmed: number
|
||||
closed: number
|
||||
}
|
||||
|
||||
export default function GrnListPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const stats = useDashboardStats()
|
||||
|
||||
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
|
||||
const [counts, setCounts] = useState<StatusCounts | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
@@ -56,6 +60,7 @@ export default function GrnListPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
const [printGrn, setPrintGrn] = useState<GrnSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
||||
@@ -101,6 +106,24 @@ export default function GrnListPage() {
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
|
||||
// the main list, so the tiles agree with whatever the table actually shows for each filter.
|
||||
// Each call falls back to 0 on its own, so a status with no GRNs (or one failed request)
|
||||
// still shows "0" instead of blanking out every tile.
|
||||
function loadCounts() {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
const countFor = (status: GrnStatus) =>
|
||||
grnsApi
|
||||
.list({ page: 1, pageSize: 1, status, branchId })
|
||||
.then((res) => res.pagination.totalItems)
|
||||
.catch(() => 0)
|
||||
|
||||
Promise.all([countFor("Draft"), countFor("Confirmed"), countFor("Closed")]).then(
|
||||
([pending, confirmed, closed]) => setCounts({ pending, confirmed, closed })
|
||||
)
|
||||
}
|
||||
useEffect(loadCounts, [activeBranchId, branchingEnabled])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
@@ -125,7 +148,35 @@ export default function GrnListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} />
|
||||
<KpiRow
|
||||
tiles={
|
||||
counts
|
||||
? [
|
||||
{
|
||||
label: "Pending GRNs",
|
||||
value: counts.pending,
|
||||
icon: Clock,
|
||||
active: status === "Draft",
|
||||
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")),
|
||||
},
|
||||
{
|
||||
label: "Confirmed GRNs",
|
||||
value: counts.confirmed,
|
||||
icon: CheckCircle2,
|
||||
active: status === "Confirmed",
|
||||
onClick: () => setStatus((s) => (s === "Confirmed" ? "All" : "Confirmed")),
|
||||
},
|
||||
{
|
||||
label: "Closed GRNs",
|
||||
value: counts.closed,
|
||||
icon: ListChecks,
|
||||
active: status === "Closed",
|
||||
onClick: () => setStatus((s) => (s === "Closed" ? "All" : "Closed")),
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
@@ -229,16 +280,22 @@ export default function GrnListPage() {
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||
setPrintGrn(grn)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||
if (e.key === "Enter") setPrintGrn(grn)
|
||||
}}
|
||||
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>
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className="block truncate font-semibold text-foreground hover:underline"
|
||||
data-no-row-nav
|
||||
>
|
||||
{grn.docNo}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
|
||||
</div>
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
@@ -314,10 +371,10 @@ export default function GrnListPage() {
|
||||
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/receiving/grn/${grn.grnId}`)
|
||||
setPrintGrn(grn)
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<TableCell data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className="font-semibold text-foreground hover:underline"
|
||||
@@ -352,7 +409,17 @@ export default function GrnListPage() {
|
||||
</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">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPrintGrn(grn)}
|
||||
aria-label={`View GRN note ${grn.docNo}`}
|
||||
title="GRN note"
|
||||
>
|
||||
<ReceiptText className="size-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
@@ -383,6 +450,13 @@ export default function GrnListPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<GrnPrintDialog
|
||||
grnId={printGrn?.grnId ?? null}
|
||||
docNo={printGrn?.docNo}
|
||||
open={printGrn !== null}
|
||||
onOpenChange={(open) => { if (!open) setPrintGrn(null) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { use, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, Undo2, X } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Minus, Plus, ReceiptText, Save, Send, Undo2, X } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -309,8 +309,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
onClick={() => setPrintOpen(true)}
|
||||
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
<ReceiptText className="size-4" />
|
||||
Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ function NewSalesInvoiceContent() {
|
||||
const bundleTemplateIdFromQuery = searchParams.get("bundleTemplateId")
|
||||
const appliedBundleRef = useRef<string | null>(null)
|
||||
const bundleGroupSeqRef = useRef(0)
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled, warehouseBranchingEnabled } = useAuth()
|
||||
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
@@ -194,9 +194,15 @@ function NewSalesInvoiceContent() {
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
// Only narrow by branch when warehouses are actually divided by branch — with that
|
||||
// sub-toggle off every warehouse sits on the default branch and is shared by all
|
||||
// branches, so filtering would leave every other branch with no warehouse to pick.
|
||||
const visibleWarehouses = useMemo(
|
||||
() => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)),
|
||||
[warehouses, effectiveBranchId]
|
||||
() =>
|
||||
effectiveBranchId === null || !warehouseBranchingEnabled
|
||||
? warehouses
|
||||
: warehouses.filter((w) => w.branchId === effectiveBranchId),
|
||||
[warehouses, effectiveBranchId, warehouseBranchingEnabled]
|
||||
)
|
||||
|
||||
// Switching branch can strip the currently-picked warehouse out of the visible set —
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
|
||||
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, ReceiptText, Tag } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -31,6 +30,10 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
type StatusFilter = SalesInvoiceStatus | "All"
|
||||
|
||||
// Mirrors the server's "overdue" definition (DashboardService.OverdueAgeDays) — a Posted
|
||||
// invoice with an outstanding balance older than this many days from its invoice date.
|
||||
const OVERDUE_AGE_DAYS = 30
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
@@ -70,7 +73,6 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||
]
|
||||
|
||||
export default function SalesInvoicesPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const stats = useDashboardStats()
|
||||
|
||||
@@ -90,6 +92,7 @@ export default function SalesInvoicesPage() {
|
||||
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [overdueOnly, setOverdueOnly] = useState(false)
|
||||
|
||||
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
||||
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
||||
@@ -164,14 +167,27 @@ export default function SalesInvoicesPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
rows?.filter((row) =>
|
||||
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||
) ?? [],
|
||||
[rows, query]
|
||||
)
|
||||
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||
const visibleRows = useMemo(() => {
|
||||
const overdueSince = new Date()
|
||||
overdueSince.setDate(overdueSince.getDate() - OVERDUE_AGE_DAYS)
|
||||
return (
|
||||
rows?.filter(
|
||||
(row) =>
|
||||
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase()) &&
|
||||
(!overdueOnly || (row.status === "Posted" && row.totals.balanceAmount > 0 && new Date(row.invoiceDate) < overdueSince))
|
||||
) ?? []
|
||||
)
|
||||
}, [rows, query, overdueOnly])
|
||||
const hasFilters =
|
||||
status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined || overdueOnly
|
||||
|
||||
function toggleOverdueOnly() {
|
||||
setOverdueOnly((prev) => {
|
||||
const next = !prev
|
||||
if (next) setStatus("Posted")
|
||||
return next
|
||||
})
|
||||
}
|
||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||
@@ -206,11 +222,17 @@ export default function SalesInvoicesPage() {
|
||||
|
||||
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as
|
||||
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
|
||||
<StatCard
|
||||
label="Overdue Invoices"
|
||||
value={stats.overdueSalesInvoices}
|
||||
icon={AlertTriangle}
|
||||
active={overdueOnly}
|
||||
onClick={toggleOverdueOnly}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-22.5 rounded-xl" />
|
||||
<Skeleton className="h-22.5 w-64 rounded-xl" />
|
||||
)}
|
||||
{rows !== null ? (
|
||||
<>
|
||||
@@ -219,7 +241,7 @@ export default function SalesInvoicesPage() {
|
||||
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -319,16 +341,22 @@ export default function SalesInvoicesPage() {
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
setPrintInvoice(row)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
if (e.key === "Enter") setPrintInvoice(row)
|
||||
}}
|
||||
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>
|
||||
<Link
|
||||
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
|
||||
className="block truncate font-semibold text-foreground hover:underline"
|
||||
data-no-row-nav
|
||||
>
|
||||
{row.invoiceNo}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
@@ -362,8 +390,8 @@ export default function SalesInvoicesPage() {
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
<ReceiptText className="size-4" />
|
||||
Invoice
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
@@ -425,10 +453,10 @@ export default function SalesInvoicesPage() {
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
setPrintInvoice(row)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-4 py-3.5 font-medium">
|
||||
<TableCell className="px-4 py-3.5 font-medium" data-no-row-nav>
|
||||
<Link href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline">
|
||||
{row.invoiceNo}
|
||||
</Link>
|
||||
@@ -455,10 +483,10 @@ export default function SalesInvoicesPage() {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPrintInvoice(row)}
|
||||
aria-label={`Print ${row.invoiceNo}`}
|
||||
title="Print"
|
||||
aria-label={`View invoice ${row.invoiceNo}`}
|
||||
title="Invoice"
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
<ReceiptText className="size-4" />
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
|
||||
import { MapPin } from "lucide-react"
|
||||
|
||||
import { branchConfigApi } from "@/lib/api/branch-config"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { BranchConfig } from "@/types/hrm"
|
||||
|
||||
@@ -22,6 +23,9 @@ export default function BranchConfigurationPage() {
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Re-pulls /auth/me after a save so the header, sidebar and branch-aware screens pick
|
||||
// up the new flags immediately instead of after a full reload.
|
||||
const { refresh: refreshAuth } = useAuth()
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
@@ -43,6 +47,7 @@ export default function BranchConfigurationPage() {
|
||||
const res = await branchConfigApi.update({ branchingEnabled: next, warehouseBranchingEnabled: config.warehouseBranchingEnabled }, etag ?? "")
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
refreshAuth()
|
||||
toast.success("Configuration saved", `Branches ${next ? "enabled" : "disabled"}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not save configuration", errorMessage(err))
|
||||
@@ -59,6 +64,7 @@ export default function BranchConfigurationPage() {
|
||||
const res = await branchConfigApi.update({ branchingEnabled: config.branchingEnabled, warehouseBranchingEnabled: next }, etag ?? "")
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
refreshAuth()
|
||||
toast.success("Configuration saved", `Warehouse branches ${next ? "enabled" : "disabled"}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not save configuration", errorMessage(err))
|
||||
@@ -68,6 +74,26 @@ export default function BranchConfigurationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePurchaseOrders(next: boolean) {
|
||||
if (!config) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await branchConfigApi.update(
|
||||
{ branchingEnabled: config.branchingEnabled, warehouseBranchingEnabled: config.warehouseBranchingEnabled, purchaseOrderBranchingEnabled: next },
|
||||
etag ?? ""
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
refreshAuth()
|
||||
toast.success("Configuration saved", `Purchase order branches ${next ? "enabled" : "disabled"}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not save configuration", errorMessage(err))
|
||||
load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -129,6 +155,25 @@ export default function BranchConfigurationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.purchaseOrderBranchingEnabled}
|
||||
onCheckedChange={togglePurchaseOrders}
|
||||
disabled={saving || !config.branchingEnabled}
|
||||
aria-label="Divide purchase orders by branch"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Divide purchase orders by branch</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: every purchase order belongs to one branch, staff only see their own branch's
|
||||
orders, and line warehouses must be in that branch. Off: purchase orders are shared
|
||||
across all branches — no branch picker or Branch column, and any warehouse can be used.
|
||||
Has no effect while "Require branches" above is off.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.updatedAt && (
|
||||
<p className="pt-2 text-sm text-muted-foreground">
|
||||
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon,
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -44,13 +44,15 @@ export default function UsersPage() {
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [search, setSearch] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<EntityStatus | undefined>(undefined)
|
||||
const [roleFilter, setRoleFilter] = useState<string | undefined>(undefined)
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
function load() {
|
||||
usersApi
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined })
|
||||
.list({ page, pageSize, q: debouncedSearch || undefined, status: statusFilter, roleCode: roleFilter })
|
||||
.then((res) => {
|
||||
setUsers(res.items)
|
||||
setPagination(res.pagination)
|
||||
@@ -64,7 +66,8 @@ export default function UsersPage() {
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, debouncedSearch])
|
||||
useEffect(load, [page, pageSize, debouncedSearch, statusFilter, roleFilter])
|
||||
useEffect(() => setPage(1), [statusFilter, roleFilter])
|
||||
|
||||
function loadStats() {
|
||||
Promise.all([
|
||||
@@ -121,13 +124,37 @@ export default function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Total users" value={stats.total} icon={UsersIcon} />
|
||||
<StatCard label="Active" value={stats.active} icon={UserCheck} />
|
||||
<StatCard label="Inactive" value={stats.total - stats.active} icon={UserX} />
|
||||
<StatCard label="Admins" value={stats.admins} icon={ShieldCheck} />
|
||||
<StatCard
|
||||
label="Total users"
|
||||
value={stats.total}
|
||||
icon={UsersIcon}
|
||||
active={statusFilter === undefined && roleFilter === undefined}
|
||||
onClick={() => { setStatusFilter(undefined); setRoleFilter(undefined) }}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
icon={UserCheck}
|
||||
active={statusFilter === "Active"}
|
||||
onClick={() => { setRoleFilter(undefined); setStatusFilter((s) => (s === "Active" ? undefined : "Active")) }}
|
||||
/>
|
||||
<StatCard
|
||||
label="Inactive"
|
||||
value={stats.total - stats.active}
|
||||
icon={UserX}
|
||||
active={statusFilter === "Inactive"}
|
||||
onClick={() => { setRoleFilter(undefined); setStatusFilter((s) => (s === "Inactive" ? undefined : "Inactive")) }}
|
||||
/>
|
||||
<StatCard
|
||||
label="Admins"
|
||||
value={stats.admins}
|
||||
icon={ShieldCheck}
|
||||
active={roleFilter === "Admin"}
|
||||
onClick={() => { setStatusFilter(undefined); setRoleFilter((r) => (r === "Admin" ? undefined : "Admin")) }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ClipboardList, Eye, Plus, Search } from "lucide-react"
|
||||
import { CheckCircle2, ClipboardCheck, ClipboardList, Eye, PenLine, Plus, Search } from "lucide-react"
|
||||
|
||||
import { stockCountsApi } from "@/lib/api/stock-counts"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { StockCountSummary } from "@/types/stock"
|
||||
import { CountStatus, StockCountSummary } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { KpiRow } from "@/components/ui/kpi-row"
|
||||
@@ -23,6 +22,14 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { CountStatusBadge } from "@/components/stock/status-badges"
|
||||
|
||||
type StatusFilter = CountStatus | "All"
|
||||
|
||||
interface StatusCounts {
|
||||
draft: number
|
||||
counted: number
|
||||
posted: number
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -31,15 +38,16 @@ const MOBILE_PAGE_SIZE_STEP = 10
|
||||
export default function CountsListPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const stats = useDashboardStats()
|
||||
|
||||
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
|
||||
const [statusCounts, setStatusCounts] = useState<StatusCounts | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
@@ -49,19 +57,25 @@ export default function CountsListPage() {
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query])
|
||||
useEffect(() => setPage(1), [query, status])
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
stockCountsApi
|
||||
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
||||
.list({
|
||||
page,
|
||||
pageSize,
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
setCounts(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, query, activeBranchId, branchingEnabled])
|
||||
}, [page, pageSize, query, status, activeBranchId, branchingEnabled])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -72,8 +86,24 @@ export default function CountsListPage() {
|
||||
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch(() => setWarehouses([]))
|
||||
}, [])
|
||||
|
||||
// Counts for the stat cards — one cheap pageSize:1 call per status, same branch scope as
|
||||
// the main list. Each call falls back to 0 on its own so one bad request doesn't blank
|
||||
// out the other tiles.
|
||||
useEffect(() => {
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
const countFor = (s: CountStatus) =>
|
||||
stockCountsApi
|
||||
.list({ page: 1, pageSize: 1, status: s, branchId })
|
||||
.then((res) => res.pagination.totalItems)
|
||||
.catch(() => 0)
|
||||
|
||||
Promise.all([countFor("Draft"), countFor("Counted"), countFor("Posted")]).then(
|
||||
([draft, counted, posted]) => setStatusCounts({ draft, counted, posted })
|
||||
)
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
const hasFilters = query.length > 0
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -90,7 +120,35 @@ export default function CountsListPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} />
|
||||
<KpiRow
|
||||
tiles={
|
||||
statusCounts
|
||||
? [
|
||||
{
|
||||
label: "Draft Counts",
|
||||
value: statusCounts.draft,
|
||||
icon: PenLine,
|
||||
active: status === "Draft",
|
||||
onClick: () => setStatus((s) => (s === "Draft" ? "All" : "Draft")),
|
||||
},
|
||||
{
|
||||
label: "Counted",
|
||||
value: statusCounts.counted,
|
||||
icon: ClipboardCheck,
|
||||
active: status === "Counted",
|
||||
onClick: () => setStatus((s) => (s === "Counted" ? "All" : "Counted")),
|
||||
},
|
||||
{
|
||||
label: "Posted Counts",
|
||||
value: statusCounts.posted,
|
||||
icon: CheckCircle2,
|
||||
active: status === "Posted",
|
||||
onClick: () => setStatus((s) => (s === "Posted" ? "All" : "Posted")),
|
||||
},
|
||||
]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
@@ -132,7 +190,7 @@ export default function CountsListPage() {
|
||||
{!error && counts !== null && counts.length === 0 && (
|
||||
<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" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No counts match your search." : "No counts yet."}</p>
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No counts match your filters." : "No counts yet."}</p>
|
||||
{!hasFilters && (
|
||||
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
@@ -52,6 +53,7 @@ export default function NewTransferPage() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [branchId, setBranchId] = useState<number | "All">("All")
|
||||
const { branchingEnabled } = useAuth()
|
||||
const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null)
|
||||
const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null)
|
||||
const [srcBinId, setSrcBinId] = useState<number | null>(null)
|
||||
@@ -117,10 +119,13 @@ export default function NewTransferPage() {
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (branchId === "All") {
|
||||
// With branching off there's no branch to pick — the server files the transfer on the
|
||||
// default branch (TransferService → ResolveBranchIdAsync ignores what's posted).
|
||||
if (branchingEnabled && branchId === "All") {
|
||||
setSubmitError("Select a branch for this transfer.")
|
||||
return
|
||||
}
|
||||
const payloadBranchId = branchingEnabled && branchId !== "All" ? branchId : 0
|
||||
if (!srcWarehouseId || !destWarehouseId) {
|
||||
setSubmitError("Select both a source and destination warehouse.")
|
||||
return
|
||||
@@ -150,7 +155,7 @@ export default function NewTransferPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, branchId, lines: payloadLines })
|
||||
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, branchId: payloadBranchId, lines: payloadLines })
|
||||
toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`)
|
||||
router.push(`/dashboard/stock/transfers/${transfer.transferId}`)
|
||||
} catch (err) {
|
||||
@@ -180,6 +185,7 @@ export default function NewTransferPage() {
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{branchingEnabled && (
|
||||
<div className="flex flex-col gap-2 sm:max-w-xs">
|
||||
<Label className="text-base">Branch</Label>
|
||||
<Select<number | "All">
|
||||
@@ -200,6 +206,7 @@ export default function NewTransferPage() {
|
||||
</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="flex flex-col gap-2">
|
||||
|
||||
@@ -37,7 +37,7 @@ function StatChip({ icon: Icon, label, value }: { icon: typeof Boxes; label: str
|
||||
function ValuationContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const { activeBranchId, warehouseBranchingEnabled } = useAuth()
|
||||
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
@@ -80,9 +80,11 @@ function ValuationContent() {
|
||||
}, [])
|
||||
|
||||
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
|
||||
// Warehouses only belong to a branch while "Divide warehouses by branch" is on (it's
|
||||
// always off when branching itself is) — otherwise they're shared, so show them all.
|
||||
const visibleWarehouses = useMemo(
|
||||
() => (branchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
|
||||
[warehouses, activeBranchId, branchingEnabled]
|
||||
() => (warehouseBranchingEnabled && activeBranchId !== null ? (warehouses ?? []).filter((w) => w.branchId === activeBranchId) : (warehouses ?? [])),
|
||||
[warehouses, activeBranchId, warehouseBranchingEnabled]
|
||||
)
|
||||
|
||||
function setParam(key: "itemId" | "warehouseId", value: number) {
|
||||
@@ -152,7 +154,7 @@ function ValuationContent() {
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(3,minmax(0,16rem))]">
|
||||
<StatChip icon={Boxes} label="Item" value={item?.sku ?? `#${itemId}`} />
|
||||
<StatChip icon={Layers} label="Total quantity" value={String(valuation.totalQty)} />
|
||||
<StatChip icon={BadgeDollarSign} label={`Total value (${valuation.currency})`} value={valuation.totalValue.toFixed(2)} />
|
||||
|
||||
+17
-2
@@ -54,6 +54,8 @@ export default function VendorDetailPage() {
|
||||
const [name, setName] = useState("")
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [address, setAddress] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [currency, setCurrency] = useState("")
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
@@ -88,6 +90,8 @@ export default function VendorDetailPage() {
|
||||
setName(data.name)
|
||||
setTerms(data.terms ?? "")
|
||||
setTaxReg(data.taxReg ?? "")
|
||||
setAddress(data.address ?? "")
|
||||
setPhone(data.phone ?? "")
|
||||
setCurrency(data.currency)
|
||||
}
|
||||
|
||||
@@ -177,7 +181,7 @@ export default function VendorDetailPage() {
|
||||
if (!vendor) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, etag)
|
||||
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, address: address || null, phone: phone || null, currency, branchId: vendor.branchId }, etag)
|
||||
setVendor(result.data)
|
||||
setEtag(result.etag)
|
||||
setEditOpen(false)
|
||||
@@ -261,6 +265,9 @@ export default function VendorDetailPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{vendor.name}</p>
|
||||
{(vendor.address || vendor.phone) && (
|
||||
<p className="text-sm text-muted-foreground">{[vendor.address, vendor.phone].filter(Boolean).join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +288,7 @@ export default function VendorDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[repeat(3,minmax(0,16rem))]">
|
||||
<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
|
||||
@@ -469,6 +476,14 @@ export default function VendorDetailPage() {
|
||||
<Label className="text-base">Tax registration</Label>
|
||||
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Address</Label>
|
||||
<Input value={address} onChange={(e) => setAddress(e.target.value)} maxLength={500} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Phone</Label>
|
||||
<Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={30} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Currency</Label>
|
||||
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
|
||||
|
||||
+13
-1
@@ -60,6 +60,8 @@ export default function VendorsPage() {
|
||||
const [name, setName] = useState("")
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [address, setAddress] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [currency, setCurrency] = useState("LKR")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -125,6 +127,8 @@ export default function VendorsPage() {
|
||||
setName("")
|
||||
setTerms("")
|
||||
setTaxReg("")
|
||||
setAddress("")
|
||||
setPhone("")
|
||||
setCurrency("LKR")
|
||||
setBranchFilterId(null)
|
||||
setErrors({})
|
||||
@@ -140,7 +144,7 @@ export default function VendorsPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: effectiveBranchId ?? 0 })
|
||||
const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, address: address || null, phone: phone || null, currency, branchId: effectiveBranchId ?? 0 })
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
@@ -212,6 +216,14 @@ export default function VendorsPage() {
|
||||
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-address">Address (optional)</FieldLabel>
|
||||
<Input id="v-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="No. 12, Main Street, Colombo 03" maxLength={500} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="v-phone" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+94 77 123 4567" maxLength={30} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.currency}>
|
||||
<FieldLabel htmlFor="v-currency">Currency</FieldLabel>
|
||||
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
|
||||
|
||||
Reference in New Issue
Block a user