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:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -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>
)
}