feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ShoppingCart, Truck } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DateRangeFilter, DateRangeValue, dateRangeBounds } from "@/components/ui/date-range-filter"
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
type SortKey = "created" | "vendor" | "total" | "status"
|
||||
type SortOrder = "asc" | "desc"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All statuses" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
{ value: "PendingApproval", label: "Pending approval" },
|
||||
{ value: "Approved", label: "Approved" },
|
||||
{ value: "PartiallyReceived", label: "Partially received" },
|
||||
{ value: "FullyReceived", label: "Fully received" },
|
||||
{ value: "Closed", label: "Closed" },
|
||||
{ value: "Rejected", label: "Rejected" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="flex items-center gap-1 hover:text-foreground" onClick={onClick}>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PoRowContext {
|
||||
/** Re-reads the current page (after a delete, submit, approve, …). */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The purchase-order table, shared by Purchase Orders and the Approval Queue so the two look and behave the
|
||||
* same: a vendor-first row (icon, name, code · contact, address — the contact and address only when switched on
|
||||
* in Procurement configuration), the created DATE, status, total, branch (admin + branching), and an Actions
|
||||
* column the caller fills in. Filters: search (doc no., vendor name/code), status (unless `fixedStatus`),
|
||||
* created-date range. Sorting and paging are server-side, so a sorted page is always a true slice.
|
||||
*/
|
||||
export function PurchaseOrderListView({
|
||||
fixedStatus,
|
||||
searchPlaceholder = "Search vendor or doc no…",
|
||||
renderActions,
|
||||
actionsHeader = "Actions",
|
||||
emptyMessage,
|
||||
onTotalChange,
|
||||
}: {
|
||||
/** Pins the list to one status (the approval queue); hides the status filter. */
|
||||
fixedStatus?: PurchaseOrderStatus
|
||||
searchPlaceholder?: string
|
||||
/** Rendered in the Actions column (desktop) and the card footer (mobile). Clicks inside don't open the PO. */
|
||||
renderActions: (po: PurchaseOrderSummary, ctx: PoRowContext) => ReactNode
|
||||
actionsHeader?: string
|
||||
emptyMessage?: ReactNode
|
||||
/** Total rows matching the current filters, e.g. for a count badge in the page header. */
|
||||
onTotalChange?: (total: number | null) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const showContact = procConfig.vendorContactMode !== "None"
|
||||
const showAddress = procConfig.vendorAddressEnabled
|
||||
const showBranch = isAdmin && branchingEnabled
|
||||
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
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 [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" })
|
||||
const [sortKey, setSortKey] = useState<SortKey>("created")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const { from, to } = dateRangeBounds(dateRange)
|
||||
// The effect depends on these primitives rather than the range object, which is a new object every render.
|
||||
const customFrom = dateRange.custom?.from?.getTime()
|
||||
const customTo = dateRange.custom?.to?.getTime()
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status, dateRange.preset, customFrom, customTo, sortKey, sortOrder])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
purchaseOrdersApi
|
||||
.list({
|
||||
page,
|
||||
pageSize,
|
||||
q: query || undefined,
|
||||
status: fixedStatus ?? (status === "All" ? undefined : status),
|
||||
from,
|
||||
to,
|
||||
sort: `${sortOrder === "desc" ? "-" : ""}${sortKey}`,
|
||||
})
|
||||
.then((res) => {
|
||||
setPos(res.items)
|
||||
setPagination(res.pagination)
|
||||
onTotalChange?.(res.pagination.totalItems)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, query, status, fixedStatus, from, to, sortKey, sortOrder])
|
||||
|
||||
// Admins bypass branch scoping server-side and see every PO — the header's branch switcher is the only
|
||||
// branch filter, applied client-side since /purchase-orders has no branchId query param.
|
||||
useEffect(() => {
|
||||
if (showBranch) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [showBranch])
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
else {
|
||||
setSortKey(key)
|
||||
// Dates and totals read best newest/largest first; names and statuses A→Z.
|
||||
setSortOrder(key === "created" || key === "total" ? "desc" : "asc")
|
||||
}
|
||||
}
|
||||
|
||||
const branchName = (branchId: number) => branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
|
||||
const visible = showBranch && activeBranchId ? (pos ?? []).filter((po) => po.branchId === activeBranchId) : pos
|
||||
const hasFilters = query.length > 0 || (!fixedStatus && status !== "All") || dateRange.preset !== "All"
|
||||
const ctx: PoRowContext = { reload: load }
|
||||
const open = (poId: number) => router.push(`/dashboard/procurement/purchase-orders/${poId}`)
|
||||
|
||||
function contactOf(po: PurchaseOrderSummary) {
|
||||
return (procConfig.vendorContactMode === "Mobile" ? po.vendor?.mobile : po.vendor?.email) || ""
|
||||
}
|
||||
|
||||
/** Vendor name, then "code · contact" and (when enabled) the address — the same lines in the table and the cards. */
|
||||
function vendorBlock(po: PurchaseOrderSummary) {
|
||||
const sub = [po.vendor?.code, showContact ? contactOf(po) : ""].filter(Boolean).join(" · ")
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Truck className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{po.vendor?.name ?? `Vendor #${po.vendorId}`}</p>
|
||||
{sub && <p className="truncate text-xs text-muted-foreground">{sub}</p>}
|
||||
{showAddress && po.vendor?.address && (
|
||||
<p className="max-w-64 truncate text-xs text-muted-foreground" title={po.vendor.address}>{po.vendor.address}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalCell = (po: PurchaseOrderSummary) => (
|
||||
<>
|
||||
<p className="font-medium text-foreground">{po.totals.currency} {formatAmount(po.totals.grandTotal)}</p>
|
||||
{procConfig.poTaxEnabled && po.totals.tax > 0 && (
|
||||
<p className="text-xs text-muted-foreground">incl. tax {formatAmount(po.totals.tax)}</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FilterBar
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
searchAriaLabel="Search purchase orders"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!fixedStatus && (
|
||||
<Select<StatusFilter>
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v ?? "All")}
|
||||
items={STATUS_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<DateRangeFilter value={dateRange} onChange={setDateRange} label="Created date" />
|
||||
{hasFilters && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setSearchInput(""); setStatus("All"); setDateRange({ preset: "All" }) }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && pos === 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 && visible !== null && visible.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShoppingCart className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No purchase orders match your search or filters." : (emptyMessage ?? "No purchase orders yet.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visible !== null && visible.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visible.map((po) => (
|
||||
<div
|
||||
key={po.poId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
open(po.poId)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !(e.target as HTMLElement).closest("[data-no-row-nav]")) open(po.poId)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{vendorBlock(po)}
|
||||
<PoStatusBadge status={po.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
{totalCell(po)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(po.createdAt)}</p>
|
||||
</div>
|
||||
{showBranch && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{branchName(po.branchId)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
{renderActions(po, ctx)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && pos !== null && pos.length < pagination.totalItems && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => { setShowMoreLoading(true); setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) }}
|
||||
disabled={showMoreLoading}
|
||||
>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table, styled like the Items table. */}
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Vendor" active={sortKey === "vendor"} order={sortOrder} onClick={() => toggleSort("vendor")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created" active={sortKey === "created"} order={sortOrder} onClick={() => toggleSort("created")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Grand total" active={sortKey === "total"} order={sortOrder} onClick={() => toggleSort("total")} />
|
||||
</TableHead>
|
||||
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">{actionsHeader}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((po) => (
|
||||
<TableRow
|
||||
key={po.poId}
|
||||
className="cursor-pointer align-top"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
open(po.poId)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5">{vendorBlock(po)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(po.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><PoStatusBadge status={po.status} /></TableCell>
|
||||
<TableCell className="px-3 py-3.5">{totalCell(po)}</TableCell>
|
||||
{showBranch && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<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={cn("px-3 py-3.5")} data-no-row-nav>
|
||||
<div className="flex items-center gap-1">{renderActions(po, ctx)}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,15 +32,26 @@ export function RfqStatusBadge({ status }: { status: RfqStatus }) {
|
||||
|
||||
function poClass(status: PurchaseOrderStatus) {
|
||||
if (status === "Draft" || status === "PendingApproval") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Cancelled") return "bg-destructive/10 text-destructive border-transparent"
|
||||
if (status === "Cancelled" || status === "Rejected") return "bg-destructive/10 text-destructive border-transparent"
|
||||
if (status === "Closed") return "bg-muted text-muted-foreground border-transparent"
|
||||
return "bg-success/10 text-success border-transparent" // Approved / PartiallyReceived / FullyReceived
|
||||
}
|
||||
|
||||
const poStatusLabel: Record<PurchaseOrderStatus, string> = {
|
||||
Draft: "Draft",
|
||||
PendingApproval: "Pending approval",
|
||||
Approved: "Approved",
|
||||
PartiallyReceived: "Partially received",
|
||||
FullyReceived: "Fully received",
|
||||
Closed: "Closed",
|
||||
Cancelled: "Cancelled",
|
||||
Rejected: "Rejected",
|
||||
}
|
||||
|
||||
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
|
||||
{status}
|
||||
{poStatusLabel[status] ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user