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,49 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { OpeningCashBankPanel } from "@/components/accounts/OpeningCashBankPanel"
|
||||
import { OpeningStockPanel } from "@/components/accounts/OpeningStockPanel"
|
||||
|
||||
const TABS = [
|
||||
{ key: "cash", label: "Cash & bank" },
|
||||
{ key: "stock", label: "Stock" },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Opening balances — what the business already had when it started using the system. Each tab posts one
|
||||
* balanced General Ledger journal against "Opening Balance Equity" (cash and bank once; stock a warehouse at a time).
|
||||
*/
|
||||
export default function OpeningBalancesPage() {
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("cash")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Opening Balances</h1>
|
||||
<p className="text-base text-muted-foreground">Bring in the cash, bank and stock you already had on the day you started.</p>
|
||||
</div>
|
||||
|
||||
<div role="tablist" aria-label="Opening balance type" className="flex w-fit gap-1 rounded-xl bg-muted p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-base font-medium transition-colors",
|
||||
tab === t.key ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "cash" ? <OpeningCashBankPanel /> : <OpeningStockPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { BookText, Inbox, PiggyBank, Receipt, Tags, Wallet, type LucideIcon } from "lucide-react"
|
||||
import { BookText, Inbox, PiggyBank, Receipt, Scale, Tags, Wallet, type LucideIcon } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
@@ -40,6 +40,12 @@ const areas: { title: string; description: string; href: string; icon: LucideIco
|
||||
href: "/dashboard/accounts/bank-deposits",
|
||||
icon: PiggyBank,
|
||||
},
|
||||
{
|
||||
title: "Opening Balances",
|
||||
description: "Enter what each cash and bank account held when you started — posted once as a single ledger journal.",
|
||||
href: "/dashboard/accounts/opening-balances",
|
||||
icon: Scale,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AccountsHubPage() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Pencil, Receipt, Save, Users } from "lucide-react"
|
||||
|
||||
import { useCustomerConfig } from "@/hooks/use-customer-config"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
@@ -61,6 +62,8 @@ export default function CustomerDetailPage() {
|
||||
const [city, setCity] = useState("")
|
||||
const [country, setCountry] = useState("")
|
||||
const [taxRegistrationNo, setTaxRegistrationNo] = useState("")
|
||||
// A field switched off in Sales Configuration is hidden; its stored value is sent back unchanged.
|
||||
const { fields } = useCustomerConfig()
|
||||
const [creditLimit, setCreditLimit] = useState("0")
|
||||
const [creditDays, setCreditDays] = useState("0")
|
||||
|
||||
@@ -502,42 +505,58 @@ export default function CustomerDetailPage() {
|
||||
<Label className="text-base">Display name (optional)</Label>
|
||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Phone</Label>
|
||||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Email</Label>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Address line 1</Label>
|
||||
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Address line 2</Label>
|
||||
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">City</Label>
|
||||
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Country</Label>
|
||||
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
{fields.phone && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Phone</Label>
|
||||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
)}
|
||||
{fields.email && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Email</Label>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
)}
|
||||
{fields.address && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Address line 1</Label>
|
||||
<Input value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Address line 2</Label>
|
||||
<Input value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{fields.city && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">City</Label>
|
||||
<Input value={city} onChange={(e) => setCity(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
)}
|
||||
{fields.country && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Country</Label>
|
||||
<Input value={country} onChange={(e) => setCountry(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax registration</Label>
|
||||
<Input value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Credit limit (LKR)</Label>
|
||||
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Credit days</Label>
|
||||
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
{fields.creditLimit && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Credit limit (LKR)</Label>
|
||||
<Input type="number" min="0" step="0.01" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
)}
|
||||
{fields.creditDays && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Credit days</Label>
|
||||
<Input type="number" min="0" step="1" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { EditCustomerDialog } from "@/components/customers/EditCustomerDialog"
|
||||
import { useCustomerConfig } from "@/hooks/use-customer-config"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
type TypeFilter = CustomerType | "All"
|
||||
@@ -63,6 +64,8 @@ export default function CustomersPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
|
||||
// Which optional customer fields Sales Configuration has on: the create dialog, the columns and the cards follow it.
|
||||
const { fields } = useCustomerConfig()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
|
||||
@@ -169,14 +172,15 @@ export default function CustomersPage() {
|
||||
customerCode: generatedCode,
|
||||
customerType: newCustomerType,
|
||||
name,
|
||||
phone: phone || null,
|
||||
email: email || null,
|
||||
addressLine1: addressLine1 || null,
|
||||
addressLine2: addressLine2 || null,
|
||||
city: city || null,
|
||||
country: country || null,
|
||||
creditLimit: Number(creditLimit) || 0,
|
||||
creditDays: Number(creditDays) || 0,
|
||||
// Only what is switched on — the server refuses a value for a field that is off.
|
||||
phone: fields.phone ? phone || null : null,
|
||||
email: fields.email ? email || null : null,
|
||||
addressLine1: fields.address ? addressLine1 || null : null,
|
||||
addressLine2: fields.address ? addressLine2 || null : null,
|
||||
city: fields.city ? city || null : null,
|
||||
country: fields.country ? country || null : null,
|
||||
creditLimit: fields.creditLimit ? Number(creditLimit) || 0 : 0,
|
||||
creditDays: fields.creditDays ? Number(creditDays) || 0 : 0,
|
||||
branchId: effectiveBranchId ?? 0,
|
||||
})
|
||||
toast.success("Customer created", `${result.data.customerCode} — ${result.data.name}`)
|
||||
@@ -265,44 +269,66 @@ export default function CustomersPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="c-phone" type="tel" inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
|
||||
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-addr1">Address line 1 (optional)</FieldLabel>
|
||||
<Input id="c-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
|
||||
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
|
||||
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-country">Country (optional)</FieldLabel>
|
||||
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
|
||||
<Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{(fields.phone || fields.email) && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{fields.phone && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="c-phone" type="tel" inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||
</Field>
|
||||
)}
|
||||
{fields.email && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-email">Email (optional)</FieldLabel>
|
||||
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fields.address && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-addr1">Address line 1 (optional)</FieldLabel>
|
||||
<Input id="c-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
|
||||
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{(fields.city || fields.country) && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{fields.city && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
|
||||
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
|
||||
</Field>
|
||||
)}
|
||||
{fields.country && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-country">Country (optional)</FieldLabel>
|
||||
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(fields.creditLimit || fields.creditDays) && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{fields.creditLimit && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||
<Input id="c-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{fields.creditDays && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="c-credit-days">Credit days</FieldLabel>
|
||||
<Input id="c-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showBranchPicker && (
|
||||
<Field data-invalid={!!errors.branch}>
|
||||
<FieldLabel htmlFor="c-branch" required>Branch</FieldLabel>
|
||||
@@ -435,14 +461,18 @@ export default function CustomersPage() {
|
||||
<p className="text-muted-foreground">Type</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center text-xs">{c.customerType}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Phone</p>
|
||||
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Credit limit</p>
|
||||
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
{fields.phone && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Phone</p>
|
||||
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
{fields.creditLimit && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Credit limit</p>
|
||||
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Last paid</p>
|
||||
<p className="font-medium text-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</p>
|
||||
@@ -512,8 +542,8 @@ export default function CustomersPage() {
|
||||
<TableHead className="w-32">Code</TableHead>
|
||||
<TableHead className="w-48">Name</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-36">Phone</TableHead>
|
||||
<TableHead className="w-36">Credit limit</TableHead>
|
||||
{fields.phone && <TableHead className="w-36">Phone</TableHead>}
|
||||
{fields.creditLimit && <TableHead className="w-36">Credit limit</TableHead>}
|
||||
<TableHead className="w-32">Last paid</TableHead>
|
||||
<TableHead className="w-36 text-right">Remaining payment</TableHead>
|
||||
<TableHead className="w-28 text-right">Unpaid invoices</TableHead>
|
||||
@@ -537,8 +567,10 @@ export default function CustomersPage() {
|
||||
<TableCell>
|
||||
<Badge variant="outline">{c.customerType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
|
||||
{fields.phone && <TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>}
|
||||
{fields.creditLimit && (
|
||||
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</TableCell>
|
||||
<TableCell className={cn("text-right text-sm tabular-nums", outstanding(c) > 0 ? "font-semibold text-foreground" : "text-muted-foreground")}>
|
||||
{outstanding(c) > 0 ? `LKR ${formatAmount(outstanding(c))}` : "—"}
|
||||
|
||||
@@ -55,7 +55,10 @@ function EmployeeDetailPageContent() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const employeeId = Number(params.id)
|
||||
const { isAdmin, branchId: myBranchId, branchingEnabled } = useAuth()
|
||||
const { isAdmin, branchId: myBranchId, branchingEnabled, can } = useAuth()
|
||||
// Bank details, salary structures and loans are pay data: they need the Payroll permission on top of Employees.
|
||||
const canSeePay = can("hrm.payroll")
|
||||
const visibleTabs = TABS.filter((t) => canSeePay || (t !== "Bank Details" && t !== "Salary & Loans"))
|
||||
|
||||
const [tab, setTab] = useState<Tab>("Overview")
|
||||
const [employee, setEmployee] = useState<EmployeeDetail | null>(null)
|
||||
@@ -331,7 +334,7 @@ function EmployeeDetailPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-1 overflow-x-auto rounded-xl bg-muted p-1">
|
||||
{TABS.map((t) => (
|
||||
{visibleTabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Header } from "@/components/Layouts/Header"
|
||||
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
|
||||
import { Toaster } from "@/components/ui/toast"
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider"
|
||||
import { AccessGate } from "@/components/auth/AccessGate"
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -20,7 +21,7 @@ export default function DashboardLayout({
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-border shadow-sm print:rounded-none print:border-0 print:shadow-none">
|
||||
<div className="p-6 print:p-0">
|
||||
{children}
|
||||
<AccessGate>{children}</AccessGate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, Ban, Check, Plus, Save, Send, Trash2, X } from "lucide-react"
|
||||
import { AlertTriangle, Ban, Check, Download, Plus, Save, Send, Trash2, X } from "lucide-react"
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -13,6 +13,10 @@ import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { lineTaxAmount, pctText, pctToRate, rateToPct } from "@/lib/po-tax"
|
||||
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
@@ -41,7 +45,10 @@ interface DraftLine {
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
tax: string
|
||||
/** Tax as a percent (18 = 18%) — converted to the API's 0..1 rate on save. */
|
||||
taxPct: string
|
||||
/** The server's stored amount; used for the read-only view (the editable view computes it live). */
|
||||
taxAmount: number
|
||||
qtyReceived: number
|
||||
}
|
||||
|
||||
@@ -55,7 +62,11 @@ export default function PurchaseOrderDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const poId = Number(params.id)
|
||||
const { isAdmin } = useAuth()
|
||||
const { can } = useAuth()
|
||||
const canApprove = can("procurement", "Approve")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const showTax = procConfig.poTaxEnabled
|
||||
const [downloadingPdf, setDownloadingPdf] = useState(false)
|
||||
|
||||
const [po, setPo] = useState<PurchaseOrder | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
@@ -89,7 +100,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
warehouseId: l.warehouseId,
|
||||
qty: String(l.qty),
|
||||
unitPrice: String(l.unitPrice),
|
||||
tax: String(l.tax),
|
||||
taxPct: pctText(rateToPct(l.tax)),
|
||||
taxAmount: l.taxAmount ?? 0,
|
||||
qtyReceived: l.qtyReceived,
|
||||
}))
|
||||
}
|
||||
@@ -131,6 +143,24 @@ export default function PurchaseOrderDetailPage() {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
/** Picking an item prefills its tax rate (when PO tax is on) — still editable per line. */
|
||||
function handleItemChange(key: string, itemId: number | null) {
|
||||
const item = itemFor(itemId)
|
||||
updateLine(key, { itemId, ...(showTax ? { taxPct: pctText(item?.taxRatePercentage ?? 0) } : {}) })
|
||||
}
|
||||
|
||||
async function handleDownloadPdf() {
|
||||
if (!po) return
|
||||
setDownloadingPdf(true)
|
||||
try {
|
||||
await downloadPurchaseOrderPdfById(po.poId, procConfig)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setDownloadingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
@@ -154,8 +184,13 @@ export default function PurchaseOrderDetailPage() {
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
tax: line.tax,
|
||||
tax: "0",
|
||||
})
|
||||
if (showTax) {
|
||||
const pct = Number(line.taxPct)
|
||||
if (line.taxPct.trim() === "" || Number.isNaN(pct) || pct < 0) errors.tax = "Tax % cannot be negative"
|
||||
else if (pct > 100) errors.tax = "Tax cannot exceed 100%"
|
||||
}
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
@@ -169,7 +204,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
warehouseId: l.warehouseId as number,
|
||||
qty: Number(l.qty),
|
||||
unitPrice: Number(l.unitPrice),
|
||||
tax: Number(l.tax),
|
||||
tax: showTax ? pctToRate(Number(l.taxPct)) : 0,
|
||||
}))
|
||||
|
||||
setSaving(true)
|
||||
@@ -200,9 +235,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
toast.success("Submitted for approval", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||
router.push("/dashboard/procurement/purchase-orders")
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not submit purchase order", errorMessage(err))
|
||||
@@ -217,9 +251,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
setApproving(true)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.approve(po.poId)
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
toast.success("Purchase order approved", updated.docNo)
|
||||
router.push("/dashboard/procurement/purchase-orders")
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
@@ -234,11 +267,10 @@ export default function PurchaseOrderDetailPage() {
|
||||
setRejecting(true)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.reject(po.poId, { reason: rejectReason.trim() || undefined })
|
||||
setPo(updated)
|
||||
setLines(toDraftLines(updated))
|
||||
setShowRejectForm(false)
|
||||
setRejectReason("")
|
||||
toast.success("Purchase order rejected", `${updated.docNo} — back to Draft for revision.`)
|
||||
toast.success("Purchase order rejected", `${updated.docNo} — marked Rejected - revise and resubmit it.`)
|
||||
router.push("/dashboard/procurement/purchase-orders")
|
||||
} catch (err) {
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not reject purchase order", errorMessage(err))
|
||||
@@ -271,9 +303,9 @@ export default function PurchaseOrderDetailPage() {
|
||||
setCancelling(true)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.cancel(po.poId, { reason: cancelReason.trim() })
|
||||
setPo(updated)
|
||||
setShowCancelForm(false)
|
||||
toast.success("Purchase order cancelled", updated.docNo)
|
||||
router.push("/dashboard/procurement/purchase-orders")
|
||||
} catch (err) {
|
||||
toast.error("Could not cancel purchase order", errorMessage(err))
|
||||
} finally {
|
||||
@@ -306,6 +338,16 @@ export default function PurchaseOrderDetailPage() {
|
||||
// A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason;
|
||||
// a Draft is deleted instead, and closed/cancelled POs are terminal.
|
||||
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
|
||||
// Editing recomputes from the lines as typed; otherwise the server's totals are the truth.
|
||||
const totals = editable
|
||||
? lines.reduce(
|
||||
(acc, l) => ({
|
||||
subTotal: acc.subTotal + (Number(l.qty) || 0) * (Number(l.unitPrice) || 0),
|
||||
tax: acc.tax + (showTax ? lineTaxAmount(Number(l.qty), Number(l.unitPrice), Number(l.taxPct)) : 0),
|
||||
}),
|
||||
{ subTotal: 0, tax: 0 },
|
||||
)
|
||||
: { subTotal: po.totals.subTotal, tax: po.totals.tax }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -317,13 +359,22 @@ export default function PurchaseOrderDetailPage() {
|
||||
<PoStatusBadge status={po.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Vendor {vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)}
|
||||
Vendor {po.vendor ? `${po.vendor.name} (${po.vendor.code})` : vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)}
|
||||
</p>
|
||||
{(po.vendor?.mobile || po.vendor?.email || po.vendor?.address) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{[po.vendor?.mobile, po.vendor?.email, po.vendor?.address].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
{po.status === "Draft" && (
|
||||
<Button variant="outline" size="lg" className="w-full sm:w-auto" onClick={handleDownloadPdf} disabled={downloadingPdf}>
|
||||
<Download className="size-5" />
|
||||
{downloadingPdf ? "Preparing…" : "Download PDF"}
|
||||
</Button>
|
||||
{(po.status === "Draft" || po.status === "Rejected") && (
|
||||
<>
|
||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||
<Send className="size-5" />
|
||||
@@ -346,7 +397,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
{po.status === "PendingApproval" && isAdmin && (
|
||||
{po.status === "PendingApproval" && canApprove && (
|
||||
<>
|
||||
<Button variant="success" size="lg" className="w-full sm:w-auto" onClick={handleApprove} disabled={approving || rejecting}>
|
||||
<Check className="size-5" />
|
||||
@@ -398,7 +449,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Reject {po.docNo}?</DialogTitle>
|
||||
<DialogDescription>Sends it back to Draft so the requester can revise and resubmit.</DialogDescription>
|
||||
<DialogDescription>Marks it Rejected so the requester can revise and resubmit it.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-reject-reason">Reason (optional)</FieldLabel>
|
||||
@@ -441,7 +492,7 @@ export default function PurchaseOrderDetailPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-foreground">Lines</h2>
|
||||
{editable && (
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, warehouseId: null, qty: "", unitPrice: "", taxPct: "0", taxAmount: 0, qtyReceived: 0 }])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
@@ -472,8 +523,11 @@ export default function PurchaseOrderDetailPage() {
|
||||
<p className="font-medium text-foreground">{line.qty} / {line.qtyReceived}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unit price / Tax</p>
|
||||
<p className="font-medium text-foreground">{Number(line.unitPrice).toFixed(2)} / {(Number(line.tax) * 100).toFixed(0)}%</p>
|
||||
<p className="text-muted-foreground">{showTax ? "Unit price / Tax" : "Unit price"}</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{Number(line.unitPrice).toFixed(2)}
|
||||
{showTax ? ` / ${line.taxPct}% (${formatAmount(line.taxAmount)})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +539,7 @@ 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 })}>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => handleItemChange(line.key, v)}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
@@ -569,20 +623,31 @@ export default function PurchaseOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel className="text-sm">Tax</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={line.tax}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
|
||||
className="h-11 w-full text-base sm:w-32"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</div>
|
||||
{showTax && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel className="text-sm">Tax %</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.taxPct}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { taxPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel className="text-sm">Tax amount</FieldLabel>
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -599,7 +664,8 @@ export default function PurchaseOrderDetailPage() {
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Received</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Tax</TableHead>
|
||||
{showTax && <TableHead className="h-12 w-24 px-3 text-sm">Tax %</TableHead>}
|
||||
{showTax && <TableHead className="h-12 w-28 px-3 text-sm">Tax amount</TableHead>}
|
||||
{editable && <TableHead className="h-12 w-10 px-3" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -616,14 +682,15 @@ export default function PurchaseOrderDetailPage() {
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{Number(line.unitPrice).toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{(Number(line.tax) * 100).toFixed(0)}%</TableCell>
|
||||
{showTax && <TableCell className="px-3 py-3.5">{line.taxPct}%</TableCell>}
|
||||
{showTax && <TableCell className="px-3 py-3.5">{formatAmount(line.taxAmount)}</TableCell>}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
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 })}>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => handleItemChange(line.key, v)}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
@@ -682,18 +749,26 @@ export default function PurchaseOrderDetailPage() {
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.tax}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</TableCell>
|
||||
{showTax && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.taxPct}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { taxPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</TableCell>
|
||||
)}
|
||||
{showTax && (
|
||||
<TableCell className="px-3 py-3.5 align-top">
|
||||
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -713,6 +788,23 @@ export default function PurchaseOrderDetailPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex w-full flex-col gap-1.5 rounded-xl border p-4 text-base sm:w-80">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sub total</span>
|
||||
<span className="font-medium text-foreground">{formatAmount(totals.subTotal)}</span>
|
||||
</div>
|
||||
{showTax && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tax</span>
|
||||
<span className="font-medium text-foreground">{formatAmount(totals.tax)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-t pt-1.5">
|
||||
<span className="font-semibold text-foreground">Total ({po.totals.currency})</span>
|
||||
<span className="font-semibold text-foreground">{formatAmount(totals.subTotal + totals.tax)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Check, CheckCircle2, Clock, ShieldAlert, X } from "lucide-react"
|
||||
import { Check, CheckCircle2, Eye, ShieldAlert, X } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -23,51 +20,31 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
import { PurchaseOrderListView } from "@/components/procurement/PurchaseOrderListView"
|
||||
|
||||
/**
|
||||
* Approvals Queue — every PO currently PendingApproval, with Approve/Reject actions
|
||||
* right here instead of having to open each PO individually. Approve/Reject are
|
||||
* server-gated to Admins who aren't the PO's own creator (FR-PROC-04); this screen
|
||||
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
|
||||
* Approval Queue — every PO currently PendingApproval, in the same table as Purchase Orders (vendor details,
|
||||
* created date, search and date filter) with Approve / Reject right on each row instead of opening each PO.
|
||||
* Approve/Reject need the Approve permission on Procurement (an administrator always has it), and nobody can approve
|
||||
* their own PO (FR-PROC-04). Anyone else still sees the queue read-only, so everyone can see what's waiting.
|
||||
*/
|
||||
export default function PurchaseOrderApprovalsPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { can } = useAuth()
|
||||
const canApprove = can("procurement", "Approve")
|
||||
|
||||
const [total, setTotal] = useState<number | null>(null)
|
||||
const [approvingId, setApprovingId] = useState<number | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<PurchaseOrderSummary | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<{ po: PurchaseOrderSummary; reload: () => void } | null>(null)
|
||||
const [rejectReason, setRejectReason] = useState("")
|
||||
const [rejectBusy, setRejectBusy] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
purchaseOrdersApi
|
||||
.list({ status: "PendingApproval", pageSize: 200 })
|
||||
.then((res) => setPos(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
useEffect(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const vendorsById = useMemo(() => new Map(vendors.map((v) => [v.vendorId, v])), [vendors])
|
||||
|
||||
async function handleApprove(po: PurchaseOrderSummary) {
|
||||
async function handleApprove(po: PurchaseOrderSummary, reload: () => void) {
|
||||
setApprovingId(po.poId)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.approve(po.poId)
|
||||
toast.success("Purchase order approved", updated.docNo)
|
||||
setPos((prev) => prev?.filter((p) => p.poId !== po.poId) ?? prev)
|
||||
reload()
|
||||
} catch (err) {
|
||||
toast.error("Could not approve purchase order", errorMessage(err))
|
||||
} finally {
|
||||
@@ -75,19 +52,16 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(po: PurchaseOrderSummary) {
|
||||
setRejectTarget(po)
|
||||
setRejectReason("")
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectTarget) return
|
||||
const { po, reload } = rejectTarget
|
||||
setRejectBusy(true)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.reject(rejectTarget.poId, { reason: rejectReason.trim() || undefined })
|
||||
toast.success("Purchase order rejected", `${updated.docNo} — back to Draft for revision.`)
|
||||
setPos((prev) => prev?.filter((p) => p.poId !== rejectTarget.poId) ?? prev)
|
||||
const updated = await purchaseOrdersApi.reject(po.poId, { reason: rejectReason.trim() || undefined })
|
||||
toast.success("Purchase order rejected", `${updated.docNo} — marked Rejected - revise and resubmit it.`)
|
||||
setRejectTarget(null)
|
||||
setRejectReason("")
|
||||
reload()
|
||||
} catch (err) {
|
||||
toast.error("Could not reject purchase order", errorMessage(err))
|
||||
} finally {
|
||||
@@ -95,18 +69,14 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loading = pos === 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">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-foreground">Approvals Queue</h1>
|
||||
{pos && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{pos.length}
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-foreground">Approval Queue</h1>
|
||||
{total !== null && (
|
||||
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">{total}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">Purchase orders awaiting approval (FR-PROC-04).</p>
|
||||
@@ -116,108 +86,65 @@ export default function PurchaseOrderApprovalsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
{!canApprove && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
|
||||
<ShieldAlert className="size-5 shrink-0" />
|
||||
You can see the queue, but only an Admin can approve or reject — and not their own PO.
|
||||
You can see the queue, but your role does not have the Approve permission on Procurement — and nobody can approve their own PO.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && pos.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<CheckCircle2 className="size-12 text-success" />
|
||||
<p className="text-base text-muted-foreground">Nothing is waiting on approval right now.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && pos.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Submitted</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pos.map((po) => {
|
||||
const vendor = vendorsById.get(po.vendorId)
|
||||
return (
|
||||
<TableRow
|
||||
key={po.poId}
|
||||
className="align-top cursor-pointer hover:bg-muted/40"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{vendor?.name ?? vendor?.code ?? `#${po.vendorId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
{new Date(po.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="success"
|
||||
disabled={!isAdmin || approvingId === po.poId}
|
||||
onClick={() => handleApprove(po)}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{approvingId === po.poId ? "Approving…" : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={!isAdmin}
|
||||
onClick={() => openReject(po)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PurchaseOrderListView
|
||||
fixedStatus="PendingApproval"
|
||||
actionsHeader="Action"
|
||||
onTotalChange={setTotal}
|
||||
emptyMessage={
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<CheckCircle2 className="size-10 text-success" />
|
||||
Nothing is waiting on approval right now.
|
||||
</span>
|
||||
}
|
||||
renderActions={(po, { reload }) => (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${po.docNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="success"
|
||||
disabled={!canApprove || approvingId === po.poId}
|
||||
onClick={() => handleApprove(po, reload)}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{approvingId === po.poId ? "Approving…" : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={!canApprove}
|
||||
onClick={() => {
|
||||
setRejectReason("")
|
||||
setRejectTarget({ po, reload })
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Dialog open={!!rejectTarget} onOpenChange={(v) => { if (!v) setRejectTarget(null) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>Reject {rejectTarget?.docNo}?</DialogTitle>
|
||||
<DialogDescription>Sends it back to Draft so the requester can revise and resubmit.</DialogDescription>
|
||||
<DialogTitle>Reject {rejectTarget?.po.docNo}?</DialogTitle>
|
||||
<DialogDescription>Marks it Rejected so the requester can revise and resubmit it.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-reject-reason">Reason (optional)</FieldLabel>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
import { ChevronDown, ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { requisitionsApi } from "@/lib/api/requisitions"
|
||||
@@ -16,9 +16,20 @@ import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validatePoLine } from "@/lib/validations/procurement"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { lineTaxAmount, pctText, pctToRate } from "@/lib/po-tax"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { CreatePoLineInput } from "@/types/procurement"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
|
||||
@@ -36,6 +47,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 { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
@@ -46,7 +58,10 @@ interface DraftLine {
|
||||
warehouseId: number | null
|
||||
qty: string
|
||||
unitPrice: string
|
||||
tax: string
|
||||
/** Tax as a percent (18 = 18%) — prefilled from the item's tax rate, editable. Only used while PO tax is on. */
|
||||
taxPct: string
|
||||
/** True once the user types a tax %, so a later item change or reload of items never overwrites it. */
|
||||
taxTouched?: boolean
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
@@ -55,12 +70,55 @@ function newKey() {
|
||||
return `poline-${keySeq}`
|
||||
}
|
||||
|
||||
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
|
||||
// there) and stays off this form, though it remains on the payload since the backend line
|
||||
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
|
||||
// from its negotiated price (below) but stays editable.
|
||||
// Tax is entered here only while Procurement configuration has "Apply tax on purchase orders" on:
|
||||
// each line then starts from its item's tax rate and stays editable. With it off the field is hidden
|
||||
// and every line is sent at 0 (the server rejects anything else). Unit price *is* entered here; a PO
|
||||
// prefilled from an RFQ starts from its negotiated price (below) but stays editable.
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||
return { key: newKey(), itemId: null, warehouseId: null, qty: "", unitPrice: "0", taxPct: "0" }
|
||||
}
|
||||
|
||||
function ItemSearchPicker({
|
||||
item,
|
||||
options,
|
||||
onSelect,
|
||||
}: {
|
||||
item: ItemListItem | null
|
||||
options: SuggestOption[]
|
||||
onSelect: (itemId: number) => void
|
||||
}) {
|
||||
const [searching, setSearching] = useState(!item)
|
||||
|
||||
if (item && !searching) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 w-full justify-between gap-2 text-left text-base font-normal"
|
||||
onClick={() => setSearching(true)}
|
||||
title={`${item.sku} — ${item.name}`}
|
||||
>
|
||||
<span className="truncate">{item.sku} — {item.name}</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SuggestInput
|
||||
options={options}
|
||||
onSelect={(value) => {
|
||||
onSelect(Number(value))
|
||||
setSearching(false)
|
||||
}}
|
||||
browsable
|
||||
autoFocus
|
||||
placeholder="Search item — SKU or name…"
|
||||
emptyText="No items match."
|
||||
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
|
||||
aria-label="Search item"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NewPurchaseOrderContent() {
|
||||
@@ -93,6 +151,8 @@ function NewPurchaseOrderContent() {
|
||||
const [vTerms, setVTerms] = useState("")
|
||||
const [vTaxReg, setVTaxReg] = useState("")
|
||||
const [vCurrency, setVCurrency] = useState("LKR")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [vOptional, setVOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
|
||||
const [vErrors, setVErrors] = useState<Record<string, string>>({})
|
||||
const [vSubmitting, setVSubmitting] = useState(false)
|
||||
|
||||
@@ -132,6 +192,7 @@ function NewPurchaseOrderContent() {
|
||||
setVTerms("")
|
||||
setVTaxReg("")
|
||||
setVCurrency("LKR")
|
||||
setVOptional(EMPTY_VENDOR_OPTIONAL)
|
||||
setVErrors({})
|
||||
}
|
||||
|
||||
@@ -139,6 +200,7 @@ function NewPurchaseOrderContent() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!vName.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
|
||||
Object.assign(nextErrors, validateVendorOptional(procConfig, vOptional))
|
||||
setVErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
@@ -147,9 +209,9 @@ function NewPurchaseOrderContent() {
|
||||
const result = await vendorsApi.create({
|
||||
code: generatedVendorCode,
|
||||
name: vName,
|
||||
terms: vTerms || null,
|
||||
taxReg: vTaxReg || null,
|
||||
...vendorTermsTaxPayload(procConfig, vTerms, vTaxReg),
|
||||
currency: vCurrency,
|
||||
...vendorOptionalPayload(procConfig, vOptional),
|
||||
})
|
||||
await loadVendors()
|
||||
setVendorId(result.data.vendorId)
|
||||
@@ -178,7 +240,7 @@ function NewPurchaseOrderContent() {
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: "0",
|
||||
tax: "0",
|
||||
taxPct: "0",
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -203,7 +265,7 @@ function NewPurchaseOrderContent() {
|
||||
warehouseId: null,
|
||||
qty: String(l.qty),
|
||||
unitPrice: cell ? String(cell.unitPrice) : "0",
|
||||
tax: "0",
|
||||
taxPct: "0",
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -244,6 +306,32 @@ function NewPurchaseOrderContent() {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
// PO tax on: every line the user has not typed a tax % into follows its item's tax rate. Keyed on the
|
||||
// item ids (not on `lines`), so typing in a line never re-triggers it. Covers picking an item, a
|
||||
// requisition/RFQ prefill and the item list finishing its load.
|
||||
const showTax = procConfig.poTaxEnabled
|
||||
const lineItemIds = lines.map((l) => l.itemId ?? 0).join(",")
|
||||
useEffect(() => {
|
||||
if (!showTax || !items) return
|
||||
setLines((prev) =>
|
||||
prev.map((l) => (l.taxTouched ? l : { ...l, taxPct: pctText(items.find((i) => i.itemId === l.itemId)?.taxRatePercentage ?? 0) })),
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showTax, items, lineItemIds])
|
||||
|
||||
const itemOptions = useMemo<SuggestOption[]>(
|
||||
() =>
|
||||
(items ?? []).map((i) => {
|
||||
const uom = (uoms ?? []).find((u) => u.uomId === i.baseUomId)?.name
|
||||
return {
|
||||
value: String(i.itemId),
|
||||
label: `${i.sku} — ${i.name}`,
|
||||
detail: uom ? `UOM: ${uom}` : undefined,
|
||||
}
|
||||
}),
|
||||
[items, uoms]
|
||||
)
|
||||
|
||||
async function handleSubmit(saveAsDraft: boolean) {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
@@ -268,8 +356,13 @@ function NewPurchaseOrderContent() {
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
unitPrice: line.unitPrice,
|
||||
tax: line.tax,
|
||||
tax: "0",
|
||||
})
|
||||
if (showTax) {
|
||||
const pct = Number(line.taxPct)
|
||||
if (line.taxPct.trim() === "" || Number.isNaN(pct) || pct < 0) errors.tax = "Tax % cannot be negative"
|
||||
else if (pct > 100) errors.tax = "Tax cannot exceed 100%"
|
||||
}
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
@@ -283,7 +376,7 @@ function NewPurchaseOrderContent() {
|
||||
warehouseId: l.warehouseId as number,
|
||||
qty: Number(l.qty),
|
||||
unitPrice: Number(l.unitPrice),
|
||||
tax: Number(l.tax),
|
||||
tax: showTax ? pctToRate(Number(l.taxPct)) : 0,
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
@@ -297,9 +390,9 @@ function NewPurchaseOrderContent() {
|
||||
})
|
||||
toast.success(
|
||||
"Purchase order created",
|
||||
saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).`
|
||||
saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — submitted for approval.`
|
||||
)
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
router.push("/dashboard/procurement/purchase-orders")
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create purchase order", errorMessage(err))
|
||||
@@ -310,12 +403,17 @@ function NewPurchaseOrderContent() {
|
||||
|
||||
const loading = !items || !uoms || !warehouses || !vendors || prefillLoading
|
||||
|
||||
const subTotal = lines.reduce((sum, l) => sum + (Number(l.qty) || 0) * (Number(l.unitPrice) || 0), 0)
|
||||
const taxTotal = showTax
|
||||
? lines.reduce((sum, l) => sum + lineTaxAmount(Number(l.qty), Number(l.unitPrice), Number(l.taxPct)), 0)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1>
|
||||
<p className="text-base text-muted-foreground">Auto-approved on creation; freely editable while open (FR-PROC-03..05).</p>
|
||||
<p className="text-base text-muted-foreground">Save as a draft to keep editing, or create and submit it for approval (FR-PROC-03..05).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -369,14 +467,25 @@ function NewPurchaseOrderContent() {
|
||||
<FieldLabel htmlFor="po-v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="po-v-code" value={generatedVendorCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<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>
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="po-v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="po-v-terms" value={vTerms} onChange={(e) => setVTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
)}
|
||||
{procConfig.vendorTaxRegEnabled && (
|
||||
<Field>
|
||||
<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>
|
||||
)}
|
||||
<VendorOptionalFields
|
||||
config={procConfig}
|
||||
values={vOptional}
|
||||
onChange={(patch) => setVOptional((prev) => ({ ...prev, ...patch }))}
|
||||
idPrefix="po-v"
|
||||
errors={vErrors}
|
||||
/>
|
||||
<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 +577,13 @@ 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>
|
||||
<div aria-invalid={!!errors.itemId}>
|
||||
<ItemSearchPicker
|
||||
item={item}
|
||||
options={itemOptions}
|
||||
onSelect={(itemId) => updateLine(line.key, { itemId })}
|
||||
/>
|
||||
</div>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
@@ -555,6 +655,32 @@ function NewPurchaseOrderContent() {
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTax && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">Tax %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={line.taxPct}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { taxPct: e.target.value, taxTouched: true })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Tax amount</Label>
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -570,6 +696,8 @@ function NewPurchaseOrderContent() {
|
||||
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||
{showTax && <TableHead className="h-12 w-24 px-3 text-sm">Tax %</TableHead>}
|
||||
{showTax && <TableHead className="h-12 w-28 px-3 text-sm">Tax amount</TableHead>}
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -584,22 +712,13 @@ 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>
|
||||
<div aria-invalid={!!errors.itemId}>
|
||||
<ItemSearchPicker
|
||||
item={item}
|
||||
options={itemOptions}
|
||||
onSelect={(itemId) => updateLine(line.key, { itemId })}
|
||||
/>
|
||||
</div>
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
@@ -648,6 +767,26 @@ function NewPurchaseOrderContent() {
|
||||
/>
|
||||
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||
</TableCell>
|
||||
{showTax && (
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
value={line.taxPct}
|
||||
aria-invalid={!!errors.tax}
|
||||
onChange={(e) => updateLine(line.key, { taxPct: e.target.value, taxTouched: true })}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
|
||||
</TableCell>
|
||||
)}
|
||||
{showTax && (
|
||||
<TableCell className="px-3 py-3.5 align-top">
|
||||
{formatAmount(lineTaxAmount(Number(line.qty), Number(line.unitPrice), Number(line.taxPct)))}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
@@ -663,6 +802,25 @@ function NewPurchaseOrderContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div className="ml-auto flex w-full flex-col gap-1.5 rounded-xl border p-4 text-base sm:w-80">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sub total</span>
|
||||
<span className="font-medium text-foreground">{formatAmount(subTotal)}</span>
|
||||
</div>
|
||||
{showTax && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tax</span>
|
||||
<span className="font-medium text-foreground">{formatAmount(taxTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-t pt-1.5">
|
||||
<span className="font-semibold text-foreground">Total</span>
|
||||
<span className="font-semibold text-foreground">{formatAmount(subTotal + taxTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
@@ -1,111 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { 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 { Clock, Download, Eye, Pencil, Plus, Send, Trash2 } from "lucide-react"
|
||||
|
||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { downloadPurchaseOrderPdfById } from "@/lib/pdf/purchase-order-download"
|
||||
import { PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { KpiRow } from "@/components/ui/kpi-row"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
|
||||
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
|
||||
import { PurchaseOrderListView } from "@/components/procurement/PurchaseOrderListView"
|
||||
|
||||
export default function PurchaseOrdersListPage() {
|
||||
const router = useRouter()
|
||||
const stats = useDashboardStats()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
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 [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [submittingId, setSubmittingId] = useState<number | null>(null)
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
const [pdfId, setPdfId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
purchaseOrdersApi
|
||||
.list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
|
||||
.then((res) => {
|
||||
setPos(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, query, status])
|
||||
useEffect(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Admins bypass branch scoping server-side and see every PO unfiltered — the header's
|
||||
// branch switcher is the only filter available here, applied client-side since
|
||||
// /purchase-orders has no branchId query param (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
|
||||
useEffect(() => {
|
||||
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [isAdmin, branchingEnabled])
|
||||
|
||||
function vendorCode(vendorId: number) {
|
||||
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
|
||||
}
|
||||
|
||||
function branchName(branchId: number) {
|
||||
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
|
||||
}
|
||||
|
||||
const visiblePos = isAdmin && branchingEnabled && activeBranchId ? (pos ?? []).filter((po) => po.branchId === activeBranchId) : pos
|
||||
|
||||
async function handleDelete(po: PurchaseOrderSummary) {
|
||||
async function handleDelete(po: PurchaseOrderSummary, reload: () => void) {
|
||||
setDeletingId(po.poId)
|
||||
try {
|
||||
await purchaseOrdersApi.remove(po.poId)
|
||||
toast.success("Draft deleted", po.docNo)
|
||||
load()
|
||||
reload()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete purchase order", errorMessage(err))
|
||||
} finally {
|
||||
@@ -113,12 +37,12 @@ export default function PurchaseOrdersListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(po: PurchaseOrderSummary) {
|
||||
async function handleSubmit(po: PurchaseOrderSummary, reload: () => void) {
|
||||
setSubmittingId(po.poId)
|
||||
try {
|
||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||
toast.success("Submitted for approval", `${updated.docNo} — now ${updated.status}.`)
|
||||
load()
|
||||
reload()
|
||||
} catch (err) {
|
||||
toast.error("Could not submit purchase order", errorMessage(err))
|
||||
} finally {
|
||||
@@ -126,7 +50,16 @@ export default function PurchaseOrdersListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
async function handleDownloadPdf(po: PurchaseOrderSummary) {
|
||||
setPdfId(po.poId)
|
||||
try {
|
||||
await downloadPurchaseOrderPdfById(po.poId, procConfig)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setPdfId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -152,320 +85,96 @@ export default function PurchaseOrdersListPage() {
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search doc no., vendor…"
|
||||
className="h-12 w-full flex-1 basis-0 text-base"
|
||||
aria-label="Search purchase orders"
|
||||
/>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
<SelectTrigger className="h-12! w-full sm:w-56 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="PendingApproval" className="text-base">Pending approval</SelectItem>
|
||||
<SelectItem value="Approved" className="text-base">Approved</SelectItem>
|
||||
<SelectItem value="PartiallyReceived" className="text-base">Partially received</SelectItem>
|
||||
<SelectItem value="FullyReceived" className="text-base">Fully received</SelectItem>
|
||||
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
|
||||
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasFilters && (
|
||||
<Button type="button" variant="ghost" onClick={() => { setSearchInput(""); setStatus("All") }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 && visiblePos !== null && visiblePos.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/filter." : "No purchase orders yet."}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visiblePos !== null && visiblePos.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visiblePos.map((po) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<div
|
||||
key={po.poId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
<PurchaseOrderListView
|
||||
renderActions={(po, { reload }) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${po.docNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{editable && (
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit draft ${po.docNo}`}
|
||||
title="Edit draft"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{po.docNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</p>
|
||||
</div>
|
||||
<PoStatusBadge status={po.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Grand total</p>
|
||||
<p className="font-medium text-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(po.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{branchName(po.branchId)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Edit draft"
|
||||
title="Edit draft"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Submit for approval"
|
||||
title="Submit for approval"
|
||||
disabled={submittingId === po.poId || deletingId === po.poId}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="success"
|
||||
title={`Submit ${po.docNo} for approval?`}
|
||||
description="It will be locked for editing while it awaits an Admin's approval."
|
||||
confirmLabel="Submit"
|
||||
onConfirm={() => handleSubmit(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || submittingId === po.poId}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete draft ${po.docNo}?`}
|
||||
description="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{pagination && pos !== null && pos.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Download PDF for ${po.docNo}`}
|
||||
title="Download PDF"
|
||||
disabled={pdfId === po.poId}
|
||||
onClick={() => handleDownloadPdf(po)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-48">Doc No</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Grand total</TableHead>
|
||||
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-44 text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visiblePos.map((po) => {
|
||||
const editable = isPoEditable(po.status)
|
||||
return (
|
||||
<TableRow
|
||||
key={po.poId}
|
||||
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/procurement/purchase-orders/${po.poId}`)
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-semibold text-foreground hover:underline">
|
||||
{po.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{vendorCode(po.vendorId)}</TableCell>
|
||||
<TableCell>
|
||||
<PoStatusBadge status={po.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(po.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell data-no-row-nav>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="View purchase order"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
{editable && (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Edit draft"
|
||||
title="Edit draft"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Submit for approval"
|
||||
title="Submit for approval"
|
||||
disabled={submittingId === po.poId || deletingId === po.poId}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="success"
|
||||
title={`Submit ${po.docNo} for approval?`}
|
||||
description="It will be locked for editing while it awaits an Admin's approval."
|
||||
confirmLabel="Submit"
|
||||
onConfirm={() => handleSubmit(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || submittingId === po.poId}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete draft ${po.docNo}?`}
|
||||
description="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(po)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Submit for approval"
|
||||
title="Submit for approval"
|
||||
disabled={submittingId === po.poId || deletingId === po.poId}
|
||||
className="text-success hover:bg-success/10 hover:text-success"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="success"
|
||||
title={`Submit ${po.docNo} for approval?`}
|
||||
description="It will be locked for editing while it awaits an Admin's approval."
|
||||
confirmLabel="Submit"
|
||||
onConfirm={() => handleSubmit(po, reload)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete draft"
|
||||
title="Delete draft"
|
||||
disabled={deletingId === po.poId || submittingId === po.poId}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete draft ${po.docNo}?`}
|
||||
description="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(po, reload)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import ItemDetailRoute from "../page"
|
||||
|
||||
export default function EditItemRoute() {
|
||||
return <ItemDetailRoute />
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useParams, usePathname, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
import { warrantyPeriodsApi } from "@/lib/api/warranty-periods"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { taxRatesApi } from "@/lib/api/tax-rates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -16,7 +17,7 @@ import { stockApi } from "@/lib/api/stock"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, ItemReorderSetting, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
import { Item, ItemReorderSetting, MeasureUnit, ProductConfig, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
import { ItemPriceHistory } from "@/types/approvals"
|
||||
|
||||
@@ -28,7 +29,6 @@ const SALES_DOC_TYPES = new Set(["SI", "SSL", "BND"])
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
@@ -40,6 +40,7 @@ import LineChart from "@/components/ui/line-chart"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
import { WarrantyFields } from "@/components/products/WarrantyFields"
|
||||
|
||||
/**
|
||||
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
|
||||
@@ -88,7 +89,9 @@ function StatChip({ icon: Icon, label, value }: { icon: typeof Package; label: s
|
||||
)
|
||||
}
|
||||
|
||||
export default function ItemDetailPage() {
|
||||
function ItemDetailPage() {
|
||||
const router = useRouter()
|
||||
const editMode = usePathname().endsWith("/edit")
|
||||
const params = useParams<{ id: string }>()
|
||||
const itemId = Number(params.id)
|
||||
|
||||
@@ -120,7 +123,7 @@ export default function ItemDetailPage() {
|
||||
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
// Not editable here — carried through unchanged so a save doesn't silently reset it.
|
||||
// Warranty: edited in the Warranty section below (options follow Product configuration).
|
||||
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
||||
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
|
||||
const [taxRateId, setTaxRateId] = useState<number | null>(null)
|
||||
@@ -145,8 +148,11 @@ export default function ItemDetailPage() {
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [imagesEnabled, setImagesEnabled] = useState(false)
|
||||
// Product configuration drives which edit fields exist: selling prices and warranty only when switched on.
|
||||
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||
const imagesEnabled = config?.itemImagesEnabled ?? false
|
||||
// Active rows of the Warranty Periods master (months), offered when custom warranty periods are on.
|
||||
const [customWarrantyMonths, setCustomWarrantyMonths] = useState<number[]>([])
|
||||
const [imagesVersion, setImagesVersion] = useState(0)
|
||||
|
||||
function applyItem(data: Item) {
|
||||
@@ -199,7 +205,8 @@ export default function ItemDetailPage() {
|
||||
setTaxRates(tr.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
|
||||
productConfig().then(setConfig).catch(() => {})
|
||||
warrantyPeriodsApi.list({ status: "Active" }).then((rows) => setCustomWarrantyMonths(rows.map((r) => r.months))).catch(() => {})
|
||||
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
|
||||
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
|
||||
priceAdjustmentsApi.itemHistory(itemId).then(setPriceHistory).catch(() => setPriceHistory([]))
|
||||
@@ -283,25 +290,17 @@ export default function ItemDetailPage() {
|
||||
)
|
||||
const currentStockQty = stockTrend.length > 0 ? stockTrend[stockTrend.length - 1].qty : 0
|
||||
|
||||
/** Opens the edit dialog fresh from the last-loaded item, discarding any unsaved
|
||||
* edits left over from a previous cancelled session. */
|
||||
function openEdit() {
|
||||
if (item) applyItem(item)
|
||||
setErrors({})
|
||||
setSaveError(null)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
if (item) applyItem(item)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
router.push(`/dashboard/products/${itemId}/edit`)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!item || !etag) return
|
||||
setSaveError(null)
|
||||
const nextErrors: Record<string, string> = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
|
||||
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
|
||||
nextErrors.warrantyPeriodMonths = "Select a warranty period"
|
||||
}
|
||||
if (lowStockAt.trim()) {
|
||||
const point = Number(lowStockAt)
|
||||
const qty = Number(reorderQty)
|
||||
@@ -350,16 +349,15 @@ export default function ItemDetailPage() {
|
||||
}
|
||||
applyItem(saved)
|
||||
setEtag(result.etag)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
toast.success("Item saved", `${result.data.sku} — ${result.data.name}`)
|
||||
if (editMode) router.push(`/dashboard/products/${item.itemId}`)
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code
|
||||
if (code === "CONCURRENCY_CONFLICT") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
setSaving(false)
|
||||
setEditOpen(false)
|
||||
return
|
||||
}
|
||||
const fe = fieldErrors(err)
|
||||
@@ -417,6 +415,177 @@ export default function ItemDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<Link
|
||||
href={`/dashboard/products/${item.itemId}`}
|
||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to item
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Edit {item.sku}</h1>
|
||||
<p className="text-base text-muted-foreground">Update this item's basic info, pricing and classification.</p>
|
||||
</div>
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>Reload</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-6 rounded-xl border bg-card p-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">SKU</Label>
|
||||
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Barcode (optional)</Label>
|
||||
<Input value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="Scan or type a barcode" aria-invalid={!!errors.barcode} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
{config?.sellingPricesEnabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling min price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMinPrice} onChange={(e) => setSellingMinPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling max price</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingMaxPrice} onChange={(e) => setSellingMaxPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling flat price (fixed value)</Label>
|
||||
<Input type="number" min="0" step="0.01" inputMode="decimal" value={sellingFlatPrice} onChange={(e) => setSellingFlatPrice(e.target.value)} placeholder="Optional" className="h-12 text-base" disabled={conflict || pricesLocked} />
|
||||
{pricesLocked && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selling prices need approval (Settings → Approvals).{" "}
|
||||
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
|
||||
Request a price change
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax rate</Label>
|
||||
<Select<string> value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)} onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="No default (0%)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
|
||||
{taxRates.map((t) => <SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">{t.name} ({t.percentage}%)</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Sales/purchase lines for this item default to this rate — see Products → Tax Rates.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict} items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}><SelectValue placeholder="Select category" /></SelectTrigger>
|
||||
<SelectContent>{categories.map((c) => <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict} items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}><SelectValue placeholder="Select base UOM" /></SelectTrigger>
|
||||
<SelectContent>{uoms.map((u) => <SelectItem key={u.uomId} value={u.uomId} className="text-base">{u.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="0" step="any" value={contentQty} disabled={conflict} onChange={(e) => setContentQty(e.target.value)} placeholder="e.g. 500" aria-invalid={!!errors.contentQty} className="h-12! text-base" />
|
||||
<Select<MeasureUnit | null> value={contentUnit} onValueChange={setContentUnit} disabled={conflict} items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}><SelectValue placeholder="Unit" /></SelectTrigger>
|
||||
<SelectContent>{CONTENT_UNITS.map((u) => <SelectItem key={u} value={u} className="text-base">{u.toLowerCase()}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError errors={[errors.contentQty ? { message: errors.contentQty } : undefined, errors.contentUnit ? { message: errors.contentUnit } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.contentBaseQty !== null && item.contentBaseUnit ? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.` : "No content size — leave blank for items with nothing measurable to hold."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
|
||||
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
|
||||
<SelectItem value="Service" className="text-base">Service</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Low stock alert (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="0" step="any" inputMode="decimal" value={lowStockAt} onChange={(e) => setLowStockAt(e.target.value)} placeholder="Alert when stock ≤" aria-label="Alert when stock is at or below" aria-invalid={!!errors.lowStockAt} className="h-12! text-base" disabled={conflict} />
|
||||
<Input type="number" min="0" step="any" inputMode="decimal" value={reorderQty} onChange={(e) => setReorderQty(e.target.value)} placeholder="Reorder qty" aria-label="Reorder quantity" aria-invalid={!!errors.reorderQty} className="h-12! text-base" disabled={conflict} />
|
||||
</div>
|
||||
<FieldError errors={[errors.lowStockAt ? { message: errors.lowStockAt } : undefined, errors.reorderQty ? { message: errors.reorderQty } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{alertIsCustom ? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level." : lowStockAt.trim() ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.` : "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId} disabled={conflict} items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}>
|
||||
<SelectTrigger className="h-12! w-full text-base"><SelectValue placeholder="Every warehouse" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
|
||||
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.code} — {w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
|
||||
</div>
|
||||
</div>
|
||||
{config?.warrantyPeriodsEnabled && (
|
||||
<WarrantyFields
|
||||
config={config}
|
||||
customMonths={customWarrantyMonths}
|
||||
warranty={warranty}
|
||||
months={warrantyPeriodMonths}
|
||||
onChange={(w, m) => {
|
||||
setWarranty(w)
|
||||
setWarrantyPeriodMonths(m)
|
||||
setErrors((prev) => ({ ...prev, warrantyPeriodMonths: "" }))
|
||||
}}
|
||||
error={errors.warrantyPeriodMonths || undefined}
|
||||
disabled={conflict}
|
||||
/>
|
||||
)}
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push(`/dashboard/products/${item.itemId}`)} disabled={saving}>Cancel</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link
|
||||
@@ -748,292 +917,7 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={(open) => (open ? openEdit() : closeEdit())}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {item.sku}</DialogTitle>
|
||||
<DialogDescription>Update this item's basic info, pricing and classification.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">SKU</Label>
|
||||
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Barcode (optional)</Label>
|
||||
<Input
|
||||
value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
placeholder="Scan or type a barcode"
|
||||
aria-invalid={!!errors.barcode}
|
||||
className="h-12 text-base"
|
||||
disabled={conflict}
|
||||
/>
|
||||
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
|
||||
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling min price</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={sellingMinPrice}
|
||||
onChange={(e) => setSellingMinPrice(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-12 text-base"
|
||||
disabled={conflict || pricesLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling max price</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={sellingMaxPrice}
|
||||
onChange={(e) => setSellingMaxPrice(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-12 text-base"
|
||||
disabled={conflict || pricesLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Selling flat price</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={sellingFlatPrice}
|
||||
onChange={(e) => setSellingFlatPrice(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-12 text-base"
|
||||
disabled={conflict || pricesLocked}
|
||||
/>
|
||||
{pricesLocked && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selling prices need approval.{" "}
|
||||
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
|
||||
Request a price change
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax rate</Label>
|
||||
<Select<string>
|
||||
value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)}
|
||||
onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))}
|
||||
disabled={conflict}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="No default (0%)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
|
||||
{taxRates.map((t) => (
|
||||
<SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">
|
||||
{t.name} ({t.percentage}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sales/purchase lines for this item default to this rate — see Products → Tax Rates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Category</Label>
|
||||
<Select<number | null>
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
disabled={conflict}
|
||||
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Base UOM</Label>
|
||||
<Select<number | null>
|
||||
value={baseUomId}
|
||||
onValueChange={setBaseUomId}
|
||||
disabled={conflict}
|
||||
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
|
||||
<SelectValue placeholder="Select base UOM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uoms.map((u) => (
|
||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
|
||||
</div>
|
||||
{/* Content size (FR-MD-02): how much one pack holds. Optional, and independent of the
|
||||
base UOM above — stock is counted in packs either way. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Content size</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={contentQty}
|
||||
disabled={conflict}
|
||||
onChange={(e) => setContentQty(e.target.value)}
|
||||
placeholder="e.g. 500"
|
||||
aria-invalid={!!errors.contentQty}
|
||||
className="h-12! text-base"
|
||||
/>
|
||||
<Select<MeasureUnit | null>
|
||||
value={contentUnit}
|
||||
onValueChange={setContentUnit}
|
||||
disabled={conflict}
|
||||
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
|
||||
<SelectValue placeholder="Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTENT_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u} className="text-base">
|
||||
{u.toLowerCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.contentQty ? { message: errors.contentQty } : undefined,
|
||||
errors.contentUnit ? { message: errors.contentUnit } : undefined,
|
||||
]}
|
||||
/>
|
||||
{/* The stored value, so the ×1000 normalisation is never a surprise. */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.contentBaseQty !== null && item.contentBaseUnit
|
||||
? `Stored as ${item.contentBaseQty} ${item.contentBaseUnit.toLowerCase()} per ${uomName(item.baseUomId)}.`
|
||||
: "No content size — leave blank for items with nothing measurable to hold."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* "Item type" now means a Color/Size dimension master — this field is the
|
||||
stock-nature one it used to be confused with (docs/11 §8). */}
|
||||
<Label className="text-base">Stock nature</Label>
|
||||
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
|
||||
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
|
||||
<SelectItem value="Service" className="text-base">Service</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Low stock alert (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={lowStockAt}
|
||||
onChange={(e) => setLowStockAt(e.target.value)}
|
||||
placeholder="Alert when stock ≤"
|
||||
aria-label="Alert when stock is at or below"
|
||||
aria-invalid={!!errors.lowStockAt}
|
||||
className="h-12! text-base"
|
||||
disabled={conflict}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={reorderQty}
|
||||
onChange={(e) => setReorderQty(e.target.value)}
|
||||
placeholder="Reorder qty"
|
||||
aria-label="Reorder quantity"
|
||||
aria-invalid={!!errors.reorderQty}
|
||||
className="h-12! text-base"
|
||||
disabled={conflict}
|
||||
/>
|
||||
</div>
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
|
||||
errors.reorderQty ? { message: errors.reorderQty } : undefined,
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{alertIsCustom
|
||||
? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level."
|
||||
: lowStockAt.trim()
|
||||
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.`
|
||||
: "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
disabled={conflict}
|
||||
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Every warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="lg" onClick={closeEdit} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
|
||||
@@ -1042,3 +926,7 @@ export default function ItemDetailPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ItemDetailRoute() {
|
||||
return <ItemDetailPage />
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ export default function NewItemPage() {
|
||||
brandsApi.list({ pageSize: 200, status: "Active" }),
|
||||
itemTypesApi.list({ pageSize: 200, status: "Active" }),
|
||||
productConfig(),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200, status: "Active" }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([cat, br, types, cfg, uoms, wh]) => {
|
||||
|
||||
@@ -579,7 +579,7 @@ export default function ItemsPage() {
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/dashboard/products/${item.itemId}`}
|
||||
href={`/dashboard/products/${item.itemId}/edit`}
|
||||
data-no-row-nav
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "self-end")}
|
||||
aria-label={`Edit ${item.sku}`}
|
||||
@@ -689,7 +689,7 @@ export default function ItemsPage() {
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/products/${item.itemId}`}
|
||||
href={`/dashboard/products/${item.itemId}/edit`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${item.sku}`}
|
||||
title="Edit"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { ArrowLeft, ArrowRight, Send } from "lucide-react"
|
||||
import { ArrowLeft, ArrowRight, Check, Send } from "lucide-react"
|
||||
|
||||
import { priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
@@ -57,6 +57,12 @@ function NewPriceAdjustmentForm() {
|
||||
const [reason, setReason] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Settings → Approvals: 0 means no approval — the change is typed, confirmed and applied at once.
|
||||
const [approvalLevels, setApprovalLevels] = useState<number | null>(null)
|
||||
const appliesImmediately = approvalLevels === 0
|
||||
useEffect(() => {
|
||||
approvalsApi.getConfig().then((c) => setApprovalLevels(c.priceAdjustmentLevels)).catch(() => setApprovalLevels(null))
|
||||
}, [])
|
||||
|
||||
// The item's latest GRN purchase prices, to price against (null while loading).
|
||||
const [purchases, setPurchases] = useState<ItemPurchasePrice[] | null>(null)
|
||||
@@ -322,14 +328,20 @@ function NewPriceAdjustmentForm() {
|
||||
</Field>
|
||||
</Step>
|
||||
|
||||
{/* 5. Send for approval */}
|
||||
{/* 5. Send for approval, or confirm and apply when approval is switched off */}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">Approvers are notified. The price changes only after approval.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{appliesImmediately
|
||||
? "Approval is switched off in Settings → Approvals, so the new price is applied as soon as you confirm."
|
||||
: "Approvers are notified. The price changes only after approval."}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/dashboard/products/price-adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
|
||||
<Button size="lg" onClick={submit} disabled={!item || saving}>
|
||||
<Send className="size-4" />
|
||||
{saving ? "Sending…" : "Send for approval"}
|
||||
{appliesImmediately ? <Check className="size-4" /> : <Send className="size-4" />}
|
||||
{saving
|
||||
? appliesImmediately ? "Applying…" : "Sending…"
|
||||
: appliesImmediately ? "Confirm and apply" : "Send for approval"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,6 +236,8 @@ export default function NewGrnPage() {
|
||||
}
|
||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
// A GRN against a purchase order belongs to that order's branch - fixed when the PO was raised - so it is never asked again.
|
||||
const [poBranchId, setPoBranchId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([])
|
||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||
@@ -265,7 +267,7 @@ export default function NewGrnPage() {
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitting, setSubmitting] = useState<"draft" | "confirm" | null>(null)
|
||||
const [refreshingItems, setRefreshingItems] = useState(false)
|
||||
const [scanError, setScanError] = useState<string | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
@@ -318,7 +320,11 @@ export default function NewGrnPage() {
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
// The branch this GRN is being raised for — no longer used to filter warehouses
|
||||
// (warehouses are shared infrastructure across branches), only to scope the document itself.
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
const effectiveBranchId = !branchingEnabled
|
||||
? null
|
||||
: mode === "po"
|
||||
? poBranchId
|
||||
: isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId
|
||||
const visibleWarehouses = warehouses ?? []
|
||||
|
||||
// Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen.
|
||||
@@ -334,7 +340,7 @@ export default function NewGrnPage() {
|
||||
|
||||
function failSubmit(message: string) {
|
||||
setSubmitError(message)
|
||||
toast.error("Could not create GRN", message)
|
||||
toast.error("Could not save the GRN", message)
|
||||
}
|
||||
|
||||
function failWarranty(lineKey: string, message: string) {
|
||||
@@ -345,6 +351,7 @@ export default function NewGrnPage() {
|
||||
function switchMode(next: Mode) {
|
||||
setMode(next)
|
||||
setPoId(null)
|
||||
setPoBranchId(null)
|
||||
setVendorId(null)
|
||||
setLines([])
|
||||
setHeaderDiscountPct("")
|
||||
@@ -355,6 +362,7 @@ export default function NewGrnPage() {
|
||||
|
||||
async function handleSelectPo(nextPoId: number | null) {
|
||||
setPoId(nextPoId)
|
||||
setPoBranchId(null)
|
||||
if (!nextPoId) {
|
||||
setLines([])
|
||||
return
|
||||
@@ -364,6 +372,7 @@ export default function NewGrnPage() {
|
||||
setHeaderError(null)
|
||||
try {
|
||||
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
|
||||
setPoBranchId(po.branchId)
|
||||
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
|
||||
if (openLines.length === 0) {
|
||||
failHeader("This purchase order has no open (unreceived) lines.")
|
||||
@@ -647,7 +656,7 @@ export default function NewGrnPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(confirmNow: boolean) {
|
||||
setSubmitError(null)
|
||||
setHeaderError(null)
|
||||
|
||||
@@ -733,7 +742,7 @@ export default function NewGrnPage() {
|
||||
}
|
||||
})
|
||||
|
||||
setSubmitting(true)
|
||||
setSubmitting(confirmNow ? "confirm" : "draft")
|
||||
try {
|
||||
const grn = await grnsApi.create({
|
||||
poId: mode === "po" ? poId : null,
|
||||
@@ -742,13 +751,26 @@ export default function NewGrnPage() {
|
||||
branchId: effectiveBranchId ?? 0,
|
||||
lines: payloadLines,
|
||||
})
|
||||
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
|
||||
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||
if (!confirmNow) {
|
||||
toast.success("GRN saved as draft", `${grn.docNo} is waiting to be confirmed.`)
|
||||
router.push("/dashboard/receiving/grn")
|
||||
return
|
||||
}
|
||||
|
||||
// Confirming posts stock and the ledger entry. If that part fails the GRN already exists as a draft, so say so
|
||||
// and land on the table, where it can be confirmed again once the cause is fixed.
|
||||
try {
|
||||
const result = await grnsApi.confirm(grn.grnId, `grn-new-${grn.grnId}`)
|
||||
toast.success("GRN confirmed", `${grn.docNo} — ${result.createdLayers.length} stock layer(s) posted. Journal ${result.glJournalNo}.`)
|
||||
} catch (err) {
|
||||
toast.error(`${grn.docNo} saved as draft, but could not be confirmed`, errorMessage(err))
|
||||
}
|
||||
router.push("/dashboard/receiving/grn")
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not create GRN", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +781,7 @@ export default function NewGrnPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New GRN</h1>
|
||||
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
|
||||
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02). Save it as a draft, or confirm it right away to post the stock.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -829,7 +851,18 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branchingEnabled && isAdmin && (
|
||||
{branchingEnabled && mode === "po" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Branch</Label>
|
||||
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
|
||||
{poBranchId
|
||||
? (branches.find((b) => b.branchId === poBranchId)?.name ?? (poBranchId === activeBranchId ? activeBranchName : null) ?? `Branch #${poBranchId}`)
|
||||
: "Taken from the purchase order"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branchingEnabled && isAdmin && mode === "direct" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Branch</Label>
|
||||
{showBranchPicker ? (
|
||||
@@ -1638,8 +1671,11 @@ export default function NewGrnPage() {
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create GRN"}
|
||||
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => handleSubmit(false)} disabled={submitting !== null}>
|
||||
{submitting === "draft" ? "Saving…" : "Save as draft"}
|
||||
</Button>
|
||||
<Button size="lg" type="button" variant="success" className="w-full sm:w-auto" onClick={() => handleSubmit(true)} disabled={submitting !== null}>
|
||||
{submitting === "confirm" ? "Confirming…" : "Confirm now"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,77 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, 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 { ArrowDown, ArrowUp, ArrowUpDown, Check, Clock, Eye, PackageSearch, Plus, Printer, Truck } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||
import { Vendor, Warehouse } from "@/types/master-data"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { GrnReceiptDialog } from "@/components/receiving/GrnReceiptPrint"
|
||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DateRangeFilter, DateRangeValue, dateRangeBounds } from "@/components/ui/date-range-filter"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { KpiRow } from "@/components/ui/kpi-row"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type StatusFilter = GrnStatus | "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: "Confirmed", label: "Confirmed" },
|
||||
{ value: "Closed", label: "Closed" },
|
||||
]
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Goods Received Notes — the same table as Purchase Orders: a vendor-first row (name, code · contact, address — the
|
||||
* contact and address only when switched on in Procurement configuration), created DATE, status, total, branch (admin
|
||||
* with branching on), and actions. A Draft can be approved (confirmed) right from its row; any GRN can print its receipt.
|
||||
*/
|
||||
export default function GrnListPage() {
|
||||
const router = useRouter()
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const stats = useDashboardStats()
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const showContact = procConfig.vendorContactMode !== "None"
|
||||
const showAddress = procConfig.vendorAddressEnabled
|
||||
const showBranch = isAdmin && branchingEnabled
|
||||
|
||||
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
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 [approvingId, setApprovingId] = useState<number | null>(null)
|
||||
const [receipt, setReceipt] = useState<GrnSummary | null>(null)
|
||||
// One idempotency key per GRN per page session, so a double click or a retry can't post the stock twice.
|
||||
const approveKeys = useRef(new Map<number, string>())
|
||||
const approveSeq = useRef(0)
|
||||
|
||||
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(() => {
|
||||
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
|
||||
warehousesApi.list({ pageSize: 200 }).then((res) => setWarehouses(res.items)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function vendorName(id: number) {
|
||||
const vendor = vendors.find((v) => v.vendorId === id)
|
||||
return vendor ? `${vendor.code} — ${vendor.name}` : `#${id}`
|
||||
}
|
||||
function warehouseName(id: number) {
|
||||
const warehouse = warehouses.find((w) => w.warehouseId === id)
|
||||
return warehouse ? `${warehouse.code} — ${warehouse.name}` : `#${id}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showBranch) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [showBranch])
|
||||
|
||||
// Debounce the search box so typing doesn't refetch on every keystroke.
|
||||
useEffect(() => {
|
||||
@@ -80,9 +118,7 @@ export default function GrnListPage() {
|
||||
}, [searchInput])
|
||||
|
||||
// Any filter change starts back at page 1.
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [query, status, activeBranchId])
|
||||
useEffect(() => setPage(1), [query, status, activeBranchId, dateRange.preset, customFrom, customTo, sortKey, sortOrder])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
@@ -93,6 +129,9 @@ export default function GrnListPage() {
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
from,
|
||||
to,
|
||||
sort: `${sortOrder === "desc" ? "-" : ""}${sortKey}`,
|
||||
})
|
||||
.then((res) => {
|
||||
setGrns(res.items)
|
||||
@@ -105,14 +144,142 @@ export default function GrnListPage() {
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled, from, to, sortKey, sortOrder])
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, query, status, activeBranchId, branchingEnabled])
|
||||
async function handleApprove(grn: GrnSummary) {
|
||||
let key = approveKeys.current.get(grn.grnId)
|
||||
if (!key) {
|
||||
approveSeq.current += 1
|
||||
key = `grn-list-${grn.grnId}-${approveSeq.current}`
|
||||
approveKeys.current.set(grn.grnId, key)
|
||||
}
|
||||
setApprovingId(grn.grnId)
|
||||
try {
|
||||
const result = await grnsApi.confirm(grn.grnId, key)
|
||||
toast.success("GRN approved", `${grn.docNo} — ${result.createdLayers.length} stock layer(s) posted. Journal ${result.glJournalNo}.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
// A failed attempt rolled back, so a later click is a fresh attempt.
|
||||
approveKeys.current.delete(grn.grnId)
|
||||
toast.error("Could not approve GRN", errorMessage(err))
|
||||
} finally {
|
||||
setApprovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
const warehouseName = (id: number) => {
|
||||
const w = warehouses.find((x) => x.warehouseId === id)
|
||||
return w ? `${w.code} — ${w.name}` : `#${id}`
|
||||
}
|
||||
const branchName = (id: number) => branches.find((b) => b.branchId === id)?.name ?? `#${id}`
|
||||
const hasFilters = query.length > 0 || status !== "All" || dateRange.preset !== "All"
|
||||
const open = (grnId: number) => router.push(`/dashboard/receiving/grn/${grnId}`)
|
||||
|
||||
function clearFilters() {
|
||||
setSearchInput("")
|
||||
setStatus("All")
|
||||
setDateRange({ preset: "All" })
|
||||
}
|
||||
|
||||
/** Vendor name, then "code · contact" and (when enabled) the address — the same lines in the table and the cards. */
|
||||
function vendorBlock(grn: GrnSummary) {
|
||||
const contact = procConfig.vendorContactMode === "Mobile" ? grn.vendor?.mobile : grn.vendor?.email
|
||||
const sub = [grn.vendor?.code, showContact ? contact : ""].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">{grn.vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
|
||||
{sub && <p className="truncate text-xs text-muted-foreground">{sub}</p>}
|
||||
{showAddress && grn.vendor?.address && (
|
||||
<p className="max-w-64 truncate text-xs text-muted-foreground" title={grn.vendor.address}>{grn.vendor.address}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Where the goods came from: "PO-… · Warehouse" under the GRN number. */
|
||||
const docBlock = (grn: GrnSummary) => (
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{grn.docNo}</p>
|
||||
<p className="text-xs text-muted-foreground" data-no-row-nav>
|
||||
{grn.poId ? (
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
|
||||
{grn.poDocNo ?? `PO ${grn.poId}`}
|
||||
</Link>
|
||||
) : (
|
||||
"Direct receipt"
|
||||
)}
|
||||
{" · "}
|
||||
{warehouseName(grn.warehouseId)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const invoiceCell = (grn: GrnSummary) =>
|
||||
grn.status !== "Confirmed" ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : grn.purchaseInvoiceId ? (
|
||||
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
|
||||
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not invoiced</span>
|
||||
)
|
||||
|
||||
/** View · Receipt · Approve (Draft only). Clicks inside don't open the row. */
|
||||
function actions(grn: GrnSummary) {
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${grn.docNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Receipt for ${grn.docNo}`}
|
||||
title="Receipt"
|
||||
onClick={() => setReceipt(grn)}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
</Button>
|
||||
{grn.status === "Draft" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button type="button" size="sm" variant="success" disabled={approvingId === grn.grnId} />}>
|
||||
<Check className="size-4" />
|
||||
{approvingId === grn.grnId ? "Approving…" : "Approve"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
title={`Approve ${grn.docNo}?`}
|
||||
description="This confirms the receipt: stock is added to the warehouse and the ledger entry is posted. A confirmed GRN can't be edited."
|
||||
confirmLabel="Approve"
|
||||
onConfirm={() => handleApprove(grn)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const totalCell = (grn: GrnSummary) => <p className="font-medium text-foreground">{formatAmount(grn.totalValue)}</p>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -131,43 +298,32 @@ export default function GrnListPage() {
|
||||
|
||||
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 basis-0">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search doc no., PO, vendor, warehouse…"
|
||||
className="h-14 w-full pl-11 text-base"
|
||||
aria-label="Search GRNs"
|
||||
/>
|
||||
<FilterBar
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder="Search GRN no., PO, vendor…"
|
||||
searchAriaLabel="Search GRNs"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
|
||||
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
||||
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
|
||||
<SelectItem value="Confirmed" className="text-base">Confirmed</SelectItem>
|
||||
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearchInput("")
|
||||
setStatus("All")
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
|
||||
@@ -199,18 +355,10 @@ export default function GrnListPage() {
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<PackageSearch className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No GRNs match your search/filter." : "No GRNs yet. Create one from an approved purchase order."}
|
||||
{hasFilters ? "No GRNs match your search or filters." : "No GRNs yet. Create one from an approved purchase order."}
|
||||
</p>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setSearchInput("")
|
||||
setStatus("All")
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="outline" size="lg" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : (
|
||||
@@ -233,146 +381,116 @@ 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}`)
|
||||
open(grn.grnId)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") router.push(`/dashboard/receiving/grn/${grn.grnId}`)
|
||||
if (e.key === "Enter" && !(e.target as HTMLElement).closest("[data-no-row-nav]")) open(grn.grnId)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{grn.docNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</p>
|
||||
</div>
|
||||
{vendorBlock(grn)}
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">PO</p>
|
||||
<p className="font-medium text-foreground" data-no-row-nav>
|
||||
{grn.poId ? (
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
|
||||
PO {String(grn.poId).padStart(2, "0")}
|
||||
</Link>
|
||||
) : (
|
||||
"Direct"
|
||||
)}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">GRN</p>
|
||||
{docBlock(grn)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Warehouse</p>
|
||||
<p className="font-medium text-foreground">{warehouseName(grn.warehouseId)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Invoice</p>
|
||||
<p className="font-medium text-foreground" data-no-row-nav>
|
||||
{grn.status !== "Confirmed" ? (
|
||||
"—"
|
||||
) : grn.purchaseInvoiceId ? (
|
||||
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
|
||||
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
|
||||
</Link>
|
||||
) : (
|
||||
"Not invoiced"
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
{totalCell(grn)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{new Date(grn.createdAt).toLocaleDateString()}</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(grn.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Invoice</p>
|
||||
<p className="font-medium text-foreground" data-no-row-nav>{invoiceCell(grn)}</p>
|
||||
</div>
|
||||
{showBranch && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{branchName(grn.branchId)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
{actions(grn)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && grns.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={showMoreLoading}>
|
||||
<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. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-40">Doc No</TableHead>
|
||||
<TableHead>PO</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-20 text-center">Actions</TableHead>
|
||||
{/* Desktop: table, styled like the Purchase Orders 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">GRN</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="Total" active={sortKey === "total"} order={sortOrder} onClick={() => toggleSort("total")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
||||
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grns.map((grn) => (
|
||||
<TableRow
|
||||
key={grn.grnId}
|
||||
className="cursor-pointer align-top"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
open(grn.grnId)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5">{vendorBlock(grn)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{docBlock(grn)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(grn.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><GrnStatusBadge status={grn.status} /></TableCell>
|
||||
<TableCell className="px-3 py-3.5">{totalCell(grn)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-sm" data-no-row-nav>{invoiceCell(grn)}</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(grn.branchId)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">{actions(grn)}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grns.map((grn) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={grn.grnId}
|
||||
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}`)
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className="font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{grn.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground" data-no-row-nav>
|
||||
{grn.poId ? (
|
||||
<Link href={`/dashboard/procurement/purchase-orders/${grn.poId}`} className="hover:underline">
|
||||
PO {String(grn.poId).padStart(2, "0")}
|
||||
</Link>
|
||||
) : (
|
||||
<span>Direct</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{vendorName(grn.vendorId)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{warehouseName(grn.warehouseId)}</TableCell>
|
||||
<TableCell>
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
</TableCell>
|
||||
<TableCell data-no-row-nav className="text-sm text-muted-foreground">
|
||||
{grn.status !== "Confirmed" ? (
|
||||
<span>—</span>
|
||||
) : grn.purchaseInvoiceId ? (
|
||||
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="hover:underline">
|
||||
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
|
||||
</Link>
|
||||
) : (
|
||||
<span>Not invoiced</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(grn.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell data-no-row-nav>
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href={`/dashboard/receiving/grn/${grn.grnId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`View ${grn.docNo}`}
|
||||
title="View"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
@@ -387,6 +505,13 @@ export default function GrnListPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<GrnReceiptDialog
|
||||
grnId={receipt?.grnId ?? null}
|
||||
docNo={receipt?.docNo}
|
||||
open={receipt !== null}
|
||||
onOpenChange={(o) => { if (!o) setReceipt(null) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
@@ -73,6 +74,17 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [
|
||||
]
|
||||
|
||||
export default function ApprovedInvoicesPage() {
|
||||
// useSearchParams needs a Suspense boundary above it.
|
||||
return (
|
||||
<Suspense>
|
||||
<ApprovedInvoices />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ApprovedInvoices() {
|
||||
// The Sales list links here with ?q=<invoice no.> so the invoice is already searched for.
|
||||
const initialQuery = useSearchParams().get("q")?.trim() ?? ""
|
||||
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
|
||||
|
||||
// Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every
|
||||
@@ -82,8 +94,8 @@ export default function ApprovedInvoicesPage() {
|
||||
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("All")
|
||||
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
|
||||
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
|
||||
const [salesSearchInput, setSalesSearchInput] = useState("")
|
||||
const [salesQuery, setSalesQuery] = useState("")
|
||||
const [salesSearchInput, setSalesSearchInput] = useState(initialQuery)
|
||||
const [salesQuery, setSalesQuery] = useState(initialQuery)
|
||||
// Server-side paging — every filter above is applied by the API, so pages and counts are exact.
|
||||
const [salesPage, setSalesPage] = useState(1)
|
||||
const [salesPageSize, setSalesPageSize] = useState(SALES_PAGE_SIZE_OPTIONS[1])
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
Receipt,
|
||||
Tag,
|
||||
Wallet,
|
||||
} from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
@@ -33,9 +35,10 @@ 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 { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { SalesInvoiceSummary } from "@/types/sales"
|
||||
import { SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -61,6 +64,15 @@ function formatAmount(n: number) {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
type StatusFilter = "All" | SalesInvoiceStatus
|
||||
|
||||
const statusFilters: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All sales" },
|
||||
{ value: "Draft", label: "Draft (pending)" },
|
||||
{ value: "Posted", label: "Approved" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
type DateRange = "All" | "Today" | "7d" | "30d" | "Custom"
|
||||
type CustomRange = { from: Date | undefined; to?: Date | undefined }
|
||||
|
||||
@@ -125,8 +137,13 @@ export default function SalesInvoicesPage() {
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||
// This screen lists drafts only; the row, its invoice no. and the invoice icon all open the
|
||||
// invoice page, where a draft is editable.
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
|
||||
// Bumped after an approval or a payment, so the page is re-read from the server.
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [payInvoice, setPayInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
// This screen lists every sale (drafts, approved, cancelled); the row and its invoice no. open the
|
||||
// invoice page, where a draft is editable and an approved sale is read-only with its payments.
|
||||
const router = useRouter()
|
||||
|
||||
function openDraft(row: SalesInvoiceSummary) {
|
||||
@@ -151,7 +168,7 @@ export default function SalesInvoicesPage() {
|
||||
setPage(1)
|
||||
setPageSize(rowsPerPage)
|
||||
setSelected(new Set())
|
||||
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
|
||||
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
// Includes the cash customer so its (walk-in) invoices can be filtered too.
|
||||
@@ -165,8 +182,9 @@ export default function SalesInvoicesPage() {
|
||||
.listInvoices({
|
||||
page,
|
||||
pageSize,
|
||||
// Drafts only — approved invoices are worked (and paid) from the Day End screen.
|
||||
status: "Draft",
|
||||
// Every sale by default; approved ones can be paid here or worked from Approved Invoices.
|
||||
status: statusFilter === "All" ? undefined : statusFilter,
|
||||
q: query || undefined,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
customerId,
|
||||
warehouseId,
|
||||
@@ -179,7 +197,7 @@ export default function SalesInvoicesPage() {
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId])
|
||||
}, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId, statusFilter, query, reloadKey])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -190,13 +208,13 @@ export default function SalesInvoicesPage() {
|
||||
setConfirmingId(row.salesInvoiceId)
|
||||
try {
|
||||
const posted = await salesApi.postInvoice(row.salesInvoiceId)
|
||||
// No longer a draft, so it leaves this list — it's now on Day End.
|
||||
setRows((prev) => prev?.filter((r) => r.salesInvoiceId !== row.salesInvoiceId) ?? prev)
|
||||
// Stays in the list, now Approved (and payable): re-read it from the server.
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(row.salesInvoiceId)
|
||||
return next
|
||||
})
|
||||
setReloadKey((k) => k + 1)
|
||||
toast.success("Invoice confirmed", posted.invoiceNo)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
@@ -221,9 +239,9 @@ export default function SalesInvoicesPage() {
|
||||
failed.push(`${row?.invoiceNo ?? id}: ${errorMessage(err)}`)
|
||||
}
|
||||
}
|
||||
setRows((prev) => prev?.filter((r) => !approved.includes(r.salesInvoiceId)) ?? prev)
|
||||
setSelected((prev) => new Set([...prev].filter((id) => !approved.includes(id))))
|
||||
setBulkApproving(false)
|
||||
if (approved.length > 0) setReloadKey((k) => k + 1)
|
||||
if (approved.length > 0) toast.success("Invoices approved", `${approved.length} invoice(s) approved.`)
|
||||
if (failed.length > 0) toast.error(`${failed.length} invoice(s) could not be approved`, failed.join("\n"))
|
||||
}
|
||||
@@ -249,13 +267,8 @@ export default function SalesInvoicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
rows?.filter((row) =>
|
||||
`${row.invoiceNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||
) ?? [],
|
||||
[rows, query]
|
||||
)
|
||||
// Search, status and dates are applied by the API, so the loaded page is shown as it comes.
|
||||
const visibleRows = useMemo(() => rows ?? [], [rows])
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sortKey) return visibleRows
|
||||
const value = (r: SalesInvoiceSummary): string | number => {
|
||||
@@ -276,12 +289,13 @@ export default function SalesInvoicesPage() {
|
||||
})
|
||||
}, [visibleRows, sortKey, sortOrder])
|
||||
|
||||
// Header "select all" ticks every draft on screen (the loaded page, after search).
|
||||
const allTicked = sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.salesInvoiceId))
|
||||
// Header "select all" ticks every draft on screen (approved and cancelled sales can't be approved again).
|
||||
const draftRows = sortedRows.filter((r) => r.status === "Draft")
|
||||
const allTicked = draftRows.length > 0 && draftRows.every((r) => selected.has(r.salesInvoiceId))
|
||||
function toggleAll(checked: boolean) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
sortedRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
|
||||
draftRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -290,14 +304,71 @@ export default function SalesInvoicesPage() {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selected.has(row.salesInvoiceId)}
|
||||
disabled={bulkApproving}
|
||||
disabled={bulkApproving || row.status !== "Draft"}
|
||||
aria-label={`Select ${row.invoiceNo}`}
|
||||
onCheckedChange={() => toggleSelected(row.salesInvoiceId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const hasFilters = dateFrom !== undefined || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||
const hasFilters = dateFrom !== undefined || query.length > 0 || customerId !== undefined || warehouseId !== undefined || statusFilter !== "All"
|
||||
|
||||
/** Open · Approve (drafts) · Pay and "Open in Approved Invoices" (approved) — the same on cards and table rows. */
|
||||
function renderRowActions(row: SalesInvoiceSummary, buttonVariant: "outline" | "ghost", size: "icon" | "icon-sm") {
|
||||
const confirming = confirmingId === row.salesInvoiceId
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={buttonVariant}
|
||||
size={size}
|
||||
onClick={() => openDraft(row)}
|
||||
aria-label={row.status === "Draft" ? `Open ${row.invoiceNo} to edit` : `View ${row.invoiceNo}`}
|
||||
title={row.status === "Draft" ? "Open draft to edit" : "View"}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={buttonVariant}
|
||||
size={size}
|
||||
disabled={confirming}
|
||||
onClick={() => handleConfirm(row)}
|
||||
aria-label={`Confirm ${row.invoiceNo}`}
|
||||
title={confirming ? "Confirming…" : "Confirm"}
|
||||
>
|
||||
{confirming ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status === "Posted" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPayInvoice(row)
|
||||
setPayOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${row.invoiceNo}`}
|
||||
>
|
||||
<Wallet className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
{row.status === "Posted" && (
|
||||
<Link
|
||||
href={`/dashboard/sales/day-end?q=${encodeURIComponent(row.invoiceNo)}`}
|
||||
className={cn(buttonVariants({ variant: buttonVariant, size }))}
|
||||
aria-label={`Open ${row.invoiceNo} in Approved Invoices`}
|
||||
title="Open in Approved Invoices"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
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)
|
||||
@@ -307,9 +378,13 @@ export default function SalesInvoicesPage() {
|
||||
<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">Sales</h1>
|
||||
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
|
||||
<p className="text-base text-muted-foreground">Every sale — pending drafts and approved invoices — with filters, approval and payment.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Link href="/dashboard/sales/day-end" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
<CheckCircle2 className="size-5" />
|
||||
Approved invoices
|
||||
</Link>
|
||||
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Sale
|
||||
@@ -317,13 +392,13 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Same order as the table: how many drafts, then Gross → Discount → Net. Draft count is every
|
||||
matching draft; the amounts are over the invoices currently listed (filters + loaded page),
|
||||
{/* Same order as the table: how many sales, then Gross → Discount → Net. The count is every
|
||||
matching sale; the amounts 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">
|
||||
{rows !== null ? (
|
||||
<>
|
||||
<StatCard label="Draft invoices" value={pagination?.totalItems ?? rows.length} icon={FileText} />
|
||||
<StatCard label="Sales" value={pagination?.totalItems ?? rows.length} icon={FileText} />
|
||||
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
|
||||
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
|
||||
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
||||
@@ -371,6 +446,21 @@ export default function SalesInvoicesPage() {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select<StatusFilter>
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v ?? "All")}
|
||||
items={statusFilters.map((s) => ({ label: s.label, value: s.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusFilters.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select<DateRange>
|
||||
value={dateRange}
|
||||
onValueChange={(v) => setDateRange(v ?? "All")}
|
||||
@@ -496,30 +586,8 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openDraft(row)}
|
||||
aria-label={`Open ${row.invoiceNo} to edit`}
|
||||
title="Open draft to edit"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={confirmingId === row.salesInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
aria-label={`Confirm ${row.invoiceNo}`}
|
||||
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
>
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||
{renderRowActions(row, "outline", "icon")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -603,31 +671,7 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => openDraft(row)}
|
||||
aria-label={`Open ${row.invoiceNo} to edit`}
|
||||
title="Open draft to edit"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={confirmingId === row.salesInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
aria-label={`Confirm ${row.invoiceNo}`}
|
||||
title={confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||
>
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">{renderRowActions(row, "ghost", "icon-sm")}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -648,6 +692,14 @@ export default function SalesInvoicesPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* After a payment the page is re-read, so Paid / Remaining and the badges come from the server. */}
|
||||
<SalesInvoicePaymentDialog
|
||||
invoice={payInvoice}
|
||||
open={payOpen}
|
||||
onOpenChange={setPayOpen}
|
||||
onPaid={() => setReloadKey((k) => k + 1)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ShieldCheck } from "lucide-react"
|
||||
|
||||
import { AccessConfig, accessConfigApi } from "@/lib/api/access-config"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Role } from "@/types/rbac"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const NONE = "__none__"
|
||||
|
||||
/**
|
||||
* Settings → Access — the default role. Every first sign-in, and every person with no role, receives it. Keep it
|
||||
* least-privilege (the seeded "No access" role has no permissions at all): whoever can register a login gets it.
|
||||
* The administrator role is never offered, and the server refuses it.
|
||||
*/
|
||||
export default function AccessSettingsPage() {
|
||||
const [config, setConfig] = useState<AccessConfig | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
accessConfigApi
|
||||
.get()
|
||||
.then((res) => {
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
useEffect(() => {
|
||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setRoles(r.items.filter((x) => !x.isAdmin))).catch(() => setRoles([]))
|
||||
}, [])
|
||||
|
||||
async function save(value: string) {
|
||||
const roleId = value === NONE ? null : Number(value)
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await accessConfigApi.update(roleId, etag ?? "")
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Default role saved", res.data.defaultRoleName ? `New sign-ins get “${res.data.defaultRoleName}”.` : "New sign-ins get no role.")
|
||||
} catch (err) {
|
||||
toast.error("Could not save", errorMessage(err))
|
||||
load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Access</h1>
|
||||
<p className="text-base text-muted-foreground">What a person can do when they have no role of their own.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
{!error && !config && <Skeleton className="h-40 w-full" />}
|
||||
|
||||
{!error && config && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Default role</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
|
||||
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
|
||||
<Select<string>
|
||||
value={config.defaultRoleId ? String(config.defaultRoleId) : NONE}
|
||||
onValueChange={(v) => v && save(v)}
|
||||
items={[{ label: "No default role", value: NONE }, ...roles.map((r) => ({ label: r.name, value: String(r.roleId) }))]}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE} className="text-base">No default role</SelectItem>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={String(r.roleId)} className="text-base">{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Given to anyone who signs in for the first time, and to anyone without a role. Choose a role with few or no permissions —
|
||||
an administrator then assigns the real one under Settings → Users. With no default, such people see “Waiting for access”.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{config.defaultRoleId && (
|
||||
<div>
|
||||
<Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => save(NONE)}>
|
||||
Clear default
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.updatedAt && (
|
||||
<p className="pt-2 text-sm text-muted-foreground">
|
||||
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useEffect, useState } from "react"
|
||||
import { Save } from "lucide-react"
|
||||
|
||||
import { approvalsApi } from "@/lib/api/approvals"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ApprovalConfig } from "@/types/approvals"
|
||||
|
||||
@@ -17,7 +19,7 @@ import { toast } from "@/components/ui/toast"
|
||||
const levelOptions = [
|
||||
{ value: "2", label: "Two approvals — two different people" },
|
||||
{ value: "1", label: "One approval" },
|
||||
{ value: "0", label: "No approval — applies immediately" },
|
||||
{ value: "0", label: "No approval — type, confirm and it applies immediately" },
|
||||
]
|
||||
|
||||
/** Settings → Approvals (docs/DUAL_APPROVAL_PLAN.md §7): how many approvals stock and price adjustments need. */
|
||||
@@ -28,7 +30,8 @@ export default function ApprovalSettingsPage() {
|
||||
const [stockThreshold, setStockThreshold] = useState("0")
|
||||
const [priceLevels, setPriceLevels] = useState("2")
|
||||
const [priceThreshold, setPriceThreshold] = useState("0")
|
||||
const [roles, setRoles] = useState("")
|
||||
const [level2Roles, setLevel2Roles] = useState<number[]>([])
|
||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function apply(c: ApprovalConfig) {
|
||||
@@ -37,11 +40,12 @@ export default function ApprovalSettingsPage() {
|
||||
setStockThreshold(String(c.stockAdjustmentLevel2ThresholdValue))
|
||||
setPriceLevels(String(c.priceAdjustmentLevels))
|
||||
setPriceThreshold(String(c.priceAdjustmentLevel2ThresholdPercent))
|
||||
setRoles(c.level2RoleCodes.join(", "))
|
||||
setLevel2Roles(c.level2RoleIds)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
approvalsApi.getConfig().then(apply).catch((err) => setError(errorMessage(err)))
|
||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setAllRoles(r.items)).catch(() => setAllRoles([]))
|
||||
}, [])
|
||||
|
||||
async function save() {
|
||||
@@ -58,7 +62,7 @@ export default function ApprovalSettingsPage() {
|
||||
stockAdjustmentLevel2ThresholdValue: sv,
|
||||
priceAdjustmentLevels: Number(priceLevels),
|
||||
priceAdjustmentLevel2ThresholdPercent: pv,
|
||||
level2RoleCodes: roles.split(",").map((r) => r.trim()).filter(Boolean),
|
||||
level2RoleIds: level2Roles,
|
||||
})
|
||||
apply(saved)
|
||||
toast.success("Approval settings saved", "They apply to requests made from now on.")
|
||||
@@ -104,7 +108,10 @@ export default function ApprovalSettingsPage() {
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-foreground">Price adjustments</h2>
|
||||
<p className="text-sm text-muted-foreground">Selling-price changes. While this is not “No approval”, prices can’t be edited directly on an item.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selling-price changes. With “No approval” the new price is typed on the item (or the price-change form), confirmed and applied at once.
|
||||
With one or two approvals, prices can’t be edited directly on an item — they go through a price change request.
|
||||
</p>
|
||||
<Field>
|
||||
<FieldLabel>Approvals required</FieldLabel>
|
||||
<Select<string> value={priceLevels} onValueChange={(v) => v && setPriceLevels(v)}>
|
||||
@@ -124,11 +131,29 @@ export default function ApprovalSettingsPage() {
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="level2-roles">Who can give the second approval (role codes)</FieldLabel>
|
||||
<Input id="level2-roles" value={roles} onChange={(e) => setRoles(e.target.value)} placeholder="e.g. Manager, Accountant — leave empty for anyone with the Approve permission" className="h-11 text-base" />
|
||||
<FieldLabel>Who can give the second approval</FieldLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allRoles.filter((r) => !r.isAdmin).map((r) => {
|
||||
const on = level2Roles.includes(r.roleId)
|
||||
return (
|
||||
<button
|
||||
key={r.roleId}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => setLevel2Roles((prev) => (on ? prev.filter((id) => id !== r.roleId) : [...prev, r.roleId]))}
|
||||
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
on ? "border-primary bg-primary/10 text-primary" : "border-input text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<FieldDescription>
|
||||
Comma-separated. Anyone giving an approval also needs the Approve permission on the screen (Settings → Roles).
|
||||
Make sure at least two people per branch can approve, or requests will wait forever.
|
||||
Pick the roles allowed to give the second approval; pick none to allow anyone with the Approve permission (administrators always can).
|
||||
Anyone giving an approval also needs the Approve permission on the screen (Settings → Roles). Make sure at least two people can approve,
|
||||
or requests will wait forever.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ScrollText } from "lucide-react"
|
||||
|
||||
import { auditLogsApi } from "@/lib/api/audit-logs"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { AuditLog } from "@/types/audit"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Settings → Audit Log — who created, changed or deleted what (and who signed in). Read-only; the API requires the
|
||||
* Audit Log permission, because the trail includes pay and personal-data changes.
|
||||
*/
|
||||
export default function AuditLogsPage() {
|
||||
const [rows, setRows] = useState<AuditLog[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [entityType, setEntityType] = useState("")
|
||||
const [userId, setUserId] = useState("")
|
||||
const [from, setFrom] = useState("")
|
||||
const [to, setTo] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
auditLogsApi
|
||||
.list({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
entityType: entityType.trim() || undefined,
|
||||
userId: userId.trim() ? Number(userId) : undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setRows(res.items)
|
||||
setPagination(res.pagination)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => !cancelled && setError(errorMessage(err)))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [page, entityType, userId, from, to])
|
||||
|
||||
// Any filter change goes back to the first page.
|
||||
function onFilter(setter: (v: string) => void) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLoading(true)
|
||||
setter(e.target.value)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ScrollText className="size-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
|
||||
<p className="text-base text-muted-foreground">Who created, changed or deleted what, and when.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input placeholder="Record type (e.g. PurchaseOrder)" value={entityType} onChange={onFilter(setEntityType)} className="h-11" />
|
||||
<Input placeholder="User id" inputMode="numeric" value={userId} onChange={onFilter(setUserId)} className="h-11" />
|
||||
<Input type="date" aria-label="From" value={from} onChange={onFilter(setFrom)} className="h-11" />
|
||||
<Input type="date" aria-label="To" value={to} onChange={onFilter(setTo)} className="h-11" />
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
{!error && (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Record</TableHead>
|
||||
<TableHead>Changes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading &&
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell colSpan={5}><Skeleton className="h-6 w-full" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">Nothing recorded for these filters.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading &&
|
||||
rows.map((r) => (
|
||||
<TableRow key={r.auditId}>
|
||||
<TableCell className="whitespace-nowrap">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>#{r.userId}</TableCell>
|
||||
<TableCell>{r.action}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{r.entityType} #{r.entityId}</TableCell>
|
||||
<TableCell className="max-w-md truncate font-mono text-xs text-muted-foreground" title={JSON.stringify(r.changeSet)}>
|
||||
{JSON.stringify(r.changeSet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages} · {pagination.totalItems} entries
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" disabled={page <= 1} onClick={() => { setLoading(true); setPage((p) => p - 1) }}>Previous</Button>
|
||||
<Button variant="outline" disabled={page >= pagination.totalPages} onClick={() => { setLoading(true); setPage((p) => p + 1) }}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
const cards = [
|
||||
{
|
||||
@@ -32,6 +32,18 @@ const cards = [
|
||||
icon: PackageCheck,
|
||||
description: "Warehouse selection and the default receiving warehouse",
|
||||
},
|
||||
{
|
||||
title: "Procurement Configuration",
|
||||
href: "/dashboard/settings/procurement-configuration",
|
||||
icon: ClipboardList,
|
||||
description: "Vendor contact and address fields, and tax on purchase orders",
|
||||
},
|
||||
{
|
||||
title: "Access",
|
||||
href: "/dashboard/settings/access",
|
||||
icon: ShieldCheck,
|
||||
description: "The default role for people who sign in without one",
|
||||
},
|
||||
{
|
||||
title: "GL Accounts",
|
||||
href: "/dashboard/settings/gl-accounts",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Settings } from "lucide-react"
|
||||
|
||||
import { procurementConfigApi } from "@/lib/api/procurement-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ProcurementConfig, UpdateProcurementConfigRequest, VendorContactMode } from "@/types/procurement"
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const CONTACT_LABEL: Record<VendorContactMode, string> = {
|
||||
None: "Off — no contact field",
|
||||
Mobile: "Mobile number",
|
||||
Email: "Email address",
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement Configuration — singleton options for vendors and purchase orders (same pattern as
|
||||
* GRN Configuration). Every flag is enforced server-side as well: a value sent for a switched-off
|
||||
* field is rejected with 422 CONFIG_DISABLED, so turning something off here hides it everywhere.
|
||||
*/
|
||||
export default function ProcurementConfigurationPage() {
|
||||
const [config, setConfig] = useState<ProcurementConfig | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
procurementConfigApi
|
||||
.get()
|
||||
.then((res) => {
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
// PUT is a full replacement — send every flag, overriding just the one changed.
|
||||
async function saveFlag(patch: Partial<UpdateProcurementConfigRequest>, message: string) {
|
||||
if (!config) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await procurementConfigApi.update(
|
||||
{
|
||||
vendorContactMode: config.vendorContactMode,
|
||||
vendorAddressEnabled: config.vendorAddressEnabled,
|
||||
vendorPaymentTermsEnabled: config.vendorPaymentTermsEnabled,
|
||||
vendorTaxRegEnabled: config.vendorTaxRegEnabled,
|
||||
poTaxEnabled: config.poTaxEnabled,
|
||||
...patch,
|
||||
},
|
||||
etag ?? "",
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Configuration saved", message)
|
||||
} catch (err) {
|
||||
toast.error("Could not save configuration", errorMessage(err))
|
||||
load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Procurement Configuration</h1>
|
||||
<p className="text-base text-muted-foreground">Choose which optional details vendors and purchase orders carry. Contact, address, payment terms and tax registration are all optional fields you can switch on or off.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && !config && <Skeleton className="h-40 w-full" />}
|
||||
|
||||
{!error && config && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Vendors</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
|
||||
<div className="flex flex-col gap-2 py-2 sm:max-w-md">
|
||||
<span className="text-base font-medium text-foreground">Vendor contact</span>
|
||||
<Select<VendorContactMode>
|
||||
value={config.vendorContactMode}
|
||||
onValueChange={(v) => {
|
||||
if (!v || v === config.vendorContactMode) return
|
||||
saveFlag({ vendorContactMode: v }, `Vendor contact: ${CONTACT_LABEL[v]}.`)
|
||||
}}
|
||||
items={(Object.keys(CONTACT_LABEL) as VendorContactMode[]).map((m) => ({ label: CONTACT_LABEL[m], value: m }))}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(CONTACT_LABEL) as VendorContactMode[]).map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-base">
|
||||
{CONTACT_LABEL[m]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Pick one contact detail to record for every vendor. It appears on the vendor forms, the
|
||||
purchase-order table and the purchase-order PDF. Details already saved are kept if you
|
||||
switch it off.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.vendorAddressEnabled}
|
||||
onCheckedChange={(next) =>
|
||||
saveFlag({ vendorAddressEnabled: next }, `Vendor address ${next ? "enabled" : "disabled"}.`)
|
||||
}
|
||||
disabled={saving}
|
||||
aria-label="Vendor company address"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Vendor company address</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: vendors can have an optional company address, shown on the purchase-order table and
|
||||
PDF. Off: the address field is hidden.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.vendorPaymentTermsEnabled}
|
||||
onCheckedChange={(next) =>
|
||||
saveFlag({ vendorPaymentTermsEnabled: next }, `Vendor payment terms ${next ? "enabled" : "disabled"}.`)
|
||||
}
|
||||
disabled={saving}
|
||||
aria-label="Vendor payment terms"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Vendor payment terms</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: vendors can have optional payment terms (e.g. NET30). Off: the field is hidden and can't be set;
|
||||
values already saved are kept.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.vendorTaxRegEnabled}
|
||||
onCheckedChange={(next) =>
|
||||
saveFlag({ vendorTaxRegEnabled: next }, `Vendor tax registration ${next ? "enabled" : "disabled"}.`)
|
||||
}
|
||||
disabled={saving}
|
||||
aria-label="Vendor tax registration"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Vendor tax registration</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: vendors can have an optional tax registration number. Off: the field is hidden and can't be set;
|
||||
numbers already saved are kept (and still copied onto purchase invoices).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Purchase orders</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config.poTaxEnabled}
|
||||
onCheckedChange={(next) => saveFlag({ poTaxEnabled: next }, `PO tax ${next ? "enabled" : "disabled"}.`)}
|
||||
disabled={saving}
|
||||
aria-label="Apply tax on purchase orders"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Apply tax on purchase orders</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
On: each PO line gets a tax rate (pre-filled from the item's tax rate, editable per line)
|
||||
and a tax amount, included in the PO total and PDF. Off: tax is hidden everywhere on
|
||||
purchase orders and every line is at 0%.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.updatedAt && (
|
||||
<p className="pt-2 text-sm text-muted-foreground">
|
||||
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,21 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/** The optional customer fields, in the order the customer form shows them. */
|
||||
const customerFieldSwitches: {
|
||||
key: "customerPhoneEnabled" | "customerEmailEnabled" | "customerAddressEnabled" | "customerCityEnabled" | "customerCountryEnabled" | "customerCreditLimitEnabled" | "customerCreditDaysEnabled"
|
||||
label: string
|
||||
help: string
|
||||
}[] = [
|
||||
{ key: "customerPhoneEnabled", label: "Phone", help: "On: customers have an optional phone number. Off: the field is hidden." },
|
||||
{ key: "customerEmailEnabled", label: "Email", help: "On: customers have an optional email address. Off: the field is hidden." },
|
||||
{ key: "customerAddressEnabled", label: "Address", help: "On: customers have optional address lines 1 and 2. Off: both are hidden." },
|
||||
{ key: "customerCityEnabled", label: "City", help: "On: customers have an optional city. Off: the field is hidden." },
|
||||
{ key: "customerCountryEnabled", label: "Country", help: "On: customers have an optional country. Off: the field is hidden." },
|
||||
{ key: "customerCreditLimitEnabled", label: "Credit limit", help: "On: customers have a credit limit (LKR). Off: the field and its column are hidden." },
|
||||
{ key: "customerCreditDaysEnabled", label: "Credit days (age)", help: "On: customers have credit days — how long an invoice may stay unpaid. Off: the field is hidden." },
|
||||
]
|
||||
|
||||
/**
|
||||
* Sales Configuration — singleton sales toggles. "Allow cash customer": on, New Sale offers a
|
||||
* generic "Cash Customer" (and defaults to it) alongside the customer list; off, a real customer
|
||||
@@ -67,6 +82,13 @@ export default function SalesConfigurationPage() {
|
||||
allowOverStockSale: config.allowOverStockSale,
|
||||
allowWarehouseSelection: config.allowWarehouseSelection,
|
||||
defaultWarehouseId: config.defaultWarehouseId,
|
||||
customerPhoneEnabled: config.customerPhoneEnabled,
|
||||
customerEmailEnabled: config.customerEmailEnabled,
|
||||
customerAddressEnabled: config.customerAddressEnabled,
|
||||
customerCityEnabled: config.customerCityEnabled,
|
||||
customerCountryEnabled: config.customerCountryEnabled,
|
||||
customerCreditLimitEnabled: config.customerCreditLimitEnabled,
|
||||
customerCreditDaysEnabled: config.customerCreditDaysEnabled,
|
||||
...patch,
|
||||
},
|
||||
etag ?? "",
|
||||
@@ -125,6 +147,32 @@ export default function SalesConfigurationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Customer fields</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which optional details a customer carries. A field that is off is hidden on the New customer, Edit
|
||||
customer and profile screens, and new values for it are refused; details already saved are kept.
|
||||
</p>
|
||||
|
||||
{customerFieldSwitches.map((f) => (
|
||||
<div key={f.key} className="flex items-start gap-4 py-2">
|
||||
<Switch
|
||||
checked={config[f.key]}
|
||||
onCheckedChange={(next) => saveFlag({ [f.key]: next }, `${f.label} ${next ? "enabled" : "disabled"} for customers.`)}
|
||||
disabled={saving}
|
||||
aria-label={f.label}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">{f.label}</span>
|
||||
<span className="text-sm text-muted-foreground">{f.help}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Item lines</h2>
|
||||
|
||||
@@ -7,6 +7,10 @@ import { Clock, IdCard, KeyRound, Laptop, Pencil, ShieldCheck } from "lucide-rea
|
||||
|
||||
import { auditLogsApi } from "@/lib/api/audit-logs"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AuditLog, LoginActivity } from "@/types/audit"
|
||||
@@ -48,12 +52,42 @@ export default function UserDetailPage() {
|
||||
|
||||
const [sendingResetLink, setSendingResetLink] = useState(false)
|
||||
|
||||
// Role assignment: the role is the ERP's own (users.RoleId); a change applies on the person's very next request.
|
||||
const { refresh: refreshMyAccess } = useAuth()
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
// The role chosen in the dropdown; until the person picks one it shows their current role.
|
||||
const [pickedRoleIdState, setPickedRoleId] = useState<number | null | undefined>(undefined)
|
||||
const pickedRoleId = pickedRoleIdState === undefined ? (user?.roleId ?? null) : pickedRoleIdState
|
||||
const [savingRole, setSavingRole] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(userId)) return
|
||||
setLoadError(null)
|
||||
usersApi.get(userId).then(setUser).catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [userId])
|
||||
|
||||
useEffect(() => {
|
||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((r) => setRoles(r.items)).catch(() => setRoles([]))
|
||||
}, [])
|
||||
|
||||
async function handleSaveRole() {
|
||||
if (!user || pickedRoleId === null || pickedRoleId === user.roleId) return
|
||||
setSavingRole(true)
|
||||
try {
|
||||
const updated = await usersApi.updateRole(user.userId, { roleId: pickedRoleId })
|
||||
setUser(updated)
|
||||
setPickedRoleId(undefined)
|
||||
toast.success("Role changed", `${updated.displayName} is now ${updated.roleName}. It applies on their next request.`)
|
||||
// It may be your own role (or a role you hold changed): re-read what you can do.
|
||||
void refreshMyAccess()
|
||||
} catch (err) {
|
||||
toast.error("Could not change the role", errorMessage(err))
|
||||
setPickedRoleId(undefined)
|
||||
} finally {
|
||||
setSavingRole(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(userId)) return
|
||||
auditLogsApi
|
||||
@@ -238,10 +272,32 @@ export default function UserDetailPage() {
|
||||
<p className="text-sm text-muted-foreground">Controls what this user can see and do.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-1 sm:max-w-sm">
|
||||
<div className="mt-5 flex flex-col gap-2 sm:max-w-sm">
|
||||
<span className="text-sm text-muted-foreground">Role</span>
|
||||
<span className="text-sm text-foreground">{user.roleName ?? "—"}</span>
|
||||
{user.roleCode && <p className="text-xs text-muted-foreground">Role code: {user.roleCode}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Select<number | null>
|
||||
value={pickedRoleId}
|
||||
onValueChange={setPickedRoleId}
|
||||
items={roles.map((r) => ({ label: r.name, value: r.roleId }))}
|
||||
disabled={savingRole}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectValue placeholder={user.roleName ?? "No role"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={r.roleId} className="text-base">{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" className="h-11 shrink-0" onClick={handleSaveRole} disabled={savingRole || pickedRoleId === null || pickedRoleId === user.roleId}>
|
||||
{savingRole ? "Saving…" : "Change role"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{user.roleCode ? `Current role code: ${user.roleCode}. ` : "No role assigned yet. "}
|
||||
The change applies on this person's very next click — they don't need to sign in again.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function UsersPage() {
|
||||
Promise.all([
|
||||
usersApi.list({ page: 1, pageSize: 1 }),
|
||||
usersApi.list({ page: 1, pageSize: 1, status: "Active" }),
|
||||
usersApi.list({ page: 1, pageSize: 1, roleCode: "Admin" }),
|
||||
usersApi.list({ page: 1, pageSize: 1, isAdmin: true }),
|
||||
])
|
||||
.then(([totalRes, activeRes, adminsRes]) =>
|
||||
setStats({ total: totalRes.pagination.totalItems, active: activeRes.pagination.totalItems, admins: adminsRes.pagination.totalItems })
|
||||
|
||||
+37
-8
@@ -11,6 +11,15 @@ import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
import { GrnSummary } from "@/types/grn"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -41,6 +50,8 @@ export default function VendorDetailPage() {
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [currency, setCurrency] = useState("")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
@@ -87,6 +98,7 @@ export default function VendorDetailPage() {
|
||||
setTerms(data.terms ?? "")
|
||||
setTaxReg(data.taxReg ?? "")
|
||||
setCurrency(data.currency)
|
||||
setOptional({ mobile: data.mobile ?? "", email: data.email ?? "", address: data.address ?? "" })
|
||||
}
|
||||
|
||||
function load() {
|
||||
@@ -169,13 +181,14 @@ export default function VendorDetailPage() {
|
||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0 || !etag) return
|
||||
|
||||
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, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: vendor.branchId }, etag)
|
||||
setVendor(result.data)
|
||||
setEtag(result.etag)
|
||||
setEditOpen(false)
|
||||
@@ -461,13 +474,29 @@ export default function VendorDetailPage() {
|
||||
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Payment terms</Label>
|
||||
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax registration</Label>
|
||||
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Payment terms</Label>
|
||||
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
|
||||
</div>
|
||||
)}
|
||||
{procConfig.vendorTaxRegEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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-4 sm:col-span-2">
|
||||
<VendorOptionalFields
|
||||
config={procConfig}
|
||||
values={optional}
|
||||
onChange={(patch) => {
|
||||
setOptional((prev) => ({ ...prev, ...patch }))
|
||||
clearErrors("mobile", "email")
|
||||
}}
|
||||
idPrefix="vd"
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" required>Currency</Label>
|
||||
|
||||
+46
-16
@@ -34,6 +34,15 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
|
||||
type StatusFilter = EntityStatus | "All"
|
||||
|
||||
@@ -62,6 +71,8 @@ export default function VendorsPage() {
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [currency, setCurrency] = useState("LKR")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -129,6 +140,7 @@ export default function VendorsPage() {
|
||||
setTerms("")
|
||||
setTaxReg("")
|
||||
setCurrency("LKR")
|
||||
setOptional(EMPTY_VENDOR_OPTIONAL)
|
||||
setBranchFilterId(null)
|
||||
setErrors({})
|
||||
}
|
||||
@@ -148,12 +160,13 @@ export default function VendorsPage() {
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
if (branchingEnabled && !effectiveBranchId) nextErrors.branch = "Select a branch"
|
||||
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
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, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: effectiveBranchId ?? 0 })
|
||||
toast.success("Vendor created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
@@ -202,7 +215,7 @@ export default function VendorsPage() {
|
||||
<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">Vendors</h1>
|
||||
<p className="text-base text-muted-foreground">Supplier master data — code, terms, tax registration, currency (FR-MD-06).</p>
|
||||
<p className="text-base text-muted-foreground">Supplier master data — code, currency and the optional details switched on in Procurement Configuration (FR-MD-06).</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -229,14 +242,29 @@ export default function VendorsPage() {
|
||||
<FieldLabel htmlFor="v-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="v-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
)}
|
||||
{procConfig.vendorTaxRegEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
)}
|
||||
<VendorOptionalFields
|
||||
config={procConfig}
|
||||
values={optional}
|
||||
onChange={(patch) => {
|
||||
setOptional((prev) => ({ ...prev, ...patch }))
|
||||
clearError("mobile")
|
||||
clearError("email")
|
||||
}}
|
||||
idPrefix="v"
|
||||
errors={errors}
|
||||
/>
|
||||
<Field data-invalid={!!errors.currency}>
|
||||
<FieldLabel htmlFor="v-currency" required>Currency</FieldLabel>
|
||||
<Input id="v-currency" value={currency} onChange={(e) => { setCurrency(e.target.value); clearError("currency") }} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
|
||||
@@ -359,10 +387,12 @@ export default function VendorsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Terms</p>
|
||||
<p className="font-medium text-foreground">{v.terms ?? "—"}</p>
|
||||
</div>
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Terms</p>
|
||||
<p className="font-medium text-foreground">{v.terms ?? "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">Currency</p>
|
||||
<p className="font-medium text-foreground">{v.currency}</p>
|
||||
@@ -429,7 +459,7 @@ export default function VendorsPage() {
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-32">Code</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Terms</TableHead>
|
||||
{procConfig.vendorPaymentTermsEnabled && <TableHead>Terms</TableHead>}
|
||||
<TableHead>Currency</TableHead>
|
||||
{isAdmin && branchingEnabled && <TableHead>Branch</TableHead>}
|
||||
<TableHead>Status</TableHead>
|
||||
@@ -449,7 +479,7 @@ export default function VendorsPage() {
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{v.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{v.terms ?? "—"}</TableCell>
|
||||
{procConfig.vendorPaymentTermsEnabled && <TableCell className="text-sm text-muted-foreground">{v.terms ?? "—"}</TableCell>}
|
||||
<TableCell className="text-sm text-muted-foreground">{v.currency}</TableCell>
|
||||
{isAdmin && branchingEnabled && (
|
||||
<TableCell>
|
||||
|
||||
@@ -67,7 +67,8 @@ import { useAuth } from "@/components/auth/AuthProvider"
|
||||
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
|
||||
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
||||
// so role-based filtering (via GET /auth/me's navCodes) can match entries here.
|
||||
const navItems: {
|
||||
// Exported: lib/nav-routes.ts derives the page -> permission map (the route guard) from this same list.
|
||||
export const navItems: {
|
||||
title: string
|
||||
code: string
|
||||
href: string
|
||||
@@ -114,6 +115,7 @@ const navItems: {
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||
{ title: "PO Approval Queue", code: "procurement.po-approvals", href: "/dashboard/procurement/purchase-orders/approvals", icon: ClipboardCheck },
|
||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck },
|
||||
],
|
||||
@@ -215,6 +217,7 @@ const navItems: {
|
||||
{ title: "Expenses", code: "accounts.expenses", href: "/dashboard/accounts/expenses", icon: ReceiptText },
|
||||
{ title: "Expense Setup", code: "accounts.expense-setup", href: "/dashboard/accounts/expense-setup", icon: Tags },
|
||||
{ title: "Bank Deposits", code: "accounts.bank-deposits", href: "/dashboard/accounts/bank-deposits", icon: PiggyBank },
|
||||
{ title: "Opening Balances", code: "accounts.opening-balances", href: "/dashboard/accounts/opening-balances", icon: Scale },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -227,6 +230,7 @@ const navItems: {
|
||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||
{ title: "Approvals", code: "settings.approvals", href: "/dashboard/settings/approvals", icon: ClipboardCheck },
|
||||
{ title: "Audit Log", code: "settings.audit-logs", href: "/dashboard/settings/audit-logs", icon: ScrollText },
|
||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
@@ -439,32 +443,19 @@ export function AppSidebar() {
|
||||
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
//
|
||||
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
|
||||
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
|
||||
// or their children server-side (stock's children specifically have no SubNavItem rows at
|
||||
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
|
||||
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
|
||||
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
// anything server-side.
|
||||
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
|
||||
|
||||
// Every section now has seeded nav rows (HRM and Sales Returns were the last gaps), so there is no
|
||||
// exemption list any more: what you see is exactly what your role grants (an administrator holds every code).
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
.filter(
|
||||
(item) =>
|
||||
bypassCodes.has(item.code) ||
|
||||
navCodes.includes(item.code) ||
|
||||
item.children?.some((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: (bypassCodes.has(item.code)
|
||||
? item.children
|
||||
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
||||
),
|
||||
children: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
|
||||
@@ -49,6 +49,7 @@ const PROCUREMENT_TITLES: Record<string, string> = {
|
||||
"/dashboard/procurement/rfqs/new": "New RFQ",
|
||||
"/dashboard/procurement/purchase-orders": "Purchase Orders",
|
||||
"/dashboard/procurement/purchase-orders/new": "New Purchase Order",
|
||||
"/dashboard/procurement/purchase-orders/approvals": "PO Approval Queue",
|
||||
"/dashboard/procurement/purchase-returns": "Purchase Returns",
|
||||
"/dashboard/procurement/purchase-returns/new": "New Purchase Return",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { CheckCircle2, Landmark, Wallet } from "lucide-react"
|
||||
|
||||
import { openingBalancesApi } from "@/lib/api/opening-balances"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
|
||||
import { CashBankOpeningState, OpeningCashBankAccount } from "@/types/opening-balances"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/** Amounts with at most 2 decimals; blank means "nothing to enter for this account". */
|
||||
function parseAmount(raw: string): { value: number | null; error?: string } {
|
||||
const text = raw.trim()
|
||||
if (text === "") return { value: null }
|
||||
const n = Number(text)
|
||||
if (!Number.isFinite(n)) return { value: null, error: "Enter a number" }
|
||||
if (Math.round(n * 100) / 100 !== n) return { value: null, error: "At most 2 decimals" }
|
||||
return { value: n }
|
||||
}
|
||||
|
||||
function AccountIcon({ type }: { type: string }) {
|
||||
const Icon = type === "Bank" ? Landmark : Wallet
|
||||
return <Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening balances — cash and bank. The amounts become ONE journal in the General Ledger (a debit to each
|
||||
* account, the offsetting credit to "Opening Balance Equity"), posted once. Once posted this panel shows the
|
||||
* record instead of the form; later corrections are made with an ordinary journal entry.
|
||||
*/
|
||||
export function OpeningCashBankPanel() {
|
||||
const [state, setState] = useState<CashBankOpeningState | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [date, setDate] = useState(todayIso())
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [posting, setPosting] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
openingBalancesApi
|
||||
.cashBank()
|
||||
.then(setState)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
// Accounts in another currency are shown but cannot take an amount: the journal is in the base currency.
|
||||
const usable = (a: OpeningCashBankAccount) => !state || a.currencyCode.toUpperCase() === state.baseCurrency.toUpperCase()
|
||||
|
||||
const total = useMemo(() => {
|
||||
let sum = 0
|
||||
for (const a of state?.accounts ?? []) {
|
||||
const { value } = parseAmount(amounts[a.glAccountCode] ?? "")
|
||||
if (value !== null && usable(a)) sum += value
|
||||
}
|
||||
return Math.round(sum * 100) / 100
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [amounts, state])
|
||||
|
||||
function validate(): { lines: { glAccountCode: string; amount: number }[]; ok: boolean } {
|
||||
const next: Record<string, string> = {}
|
||||
const lines: { glAccountCode: string; amount: number }[] = []
|
||||
for (const a of state?.accounts ?? []) {
|
||||
if (!usable(a)) continue
|
||||
const { value, error: err } = parseAmount(amounts[a.glAccountCode] ?? "")
|
||||
if (err) next[a.glAccountCode] = err
|
||||
else if (value !== null && value !== 0) lines.push({ glAccountCode: a.glAccountCode, amount: value })
|
||||
}
|
||||
if (!date) next.date = "Choose the opening date"
|
||||
else if (date > todayIso()) next.date = "The opening date cannot be in the future"
|
||||
if (lines.length === 0 && Object.keys(next).length === 0) next.lines = "Enter an amount for at least one account"
|
||||
setErrors(next)
|
||||
return { lines, ok: Object.keys(next).length === 0 }
|
||||
}
|
||||
|
||||
async function post() {
|
||||
const { lines, ok } = validate()
|
||||
if (!ok) return
|
||||
setPosting(true)
|
||||
try {
|
||||
const run = await openingBalancesApi.postCashBank({ cutoverDate: date, lines })
|
||||
toast.success("Opening balances posted", `Journal ${run.glJournalNo ?? ""} — ${run.currency} ${formatAmount(run.totalAmount)}`)
|
||||
load()
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code
|
||||
toast.error("Could not post the opening balances", errorMessage(err))
|
||||
// Already done by someone else: show the posted record instead of an empty form.
|
||||
if (code === "OPENING_BALANCE_ALREADY_POSTED") load()
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
if (!state) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
const posted = state.posted
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-base text-muted-foreground">
|
||||
Enter what each cash and bank account held when you started using the system. It is posted once, as a single
|
||||
General Ledger journal against <span className="font-medium text-foreground">Opening Balance Equity</span>.
|
||||
</p>
|
||||
|
||||
{posted ? (
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 size-6 shrink-0 text-success" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Cash and bank opening balances are posted</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
As at {formatReportDate(posted.cutoverDate)} · journal <span className="font-medium text-foreground">{posted.glJournalNo ?? "—"}</span> ·
|
||||
posted {new Date(posted.postedAt).toLocaleString()}. To change an amount, post a correcting journal entry in the General Ledger.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead className="text-right">Opening balance ({posted.currency})</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{posted.lines.map((l) => (
|
||||
<TableRow key={l.accountCode}>
|
||||
<TableCell className="font-medium text-foreground">{l.accountName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{l.accountCode}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(l.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
|
||||
<TableCell colSpan={2}>Credited to Opening Balance Equity</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(posted.totalAmount)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<Field data-invalid={!!errors.date} className="sm:max-w-xs">
|
||||
<FieldLabel htmlFor="opening-date" required>Opening date</FieldLabel>
|
||||
<Input
|
||||
id="opening-date"
|
||||
type="date"
|
||||
max={todayIso()}
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value)
|
||||
setErrors((p) => ({ ...p, date: "" }))
|
||||
}}
|
||||
aria-invalid={!!errors.date}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.date ? { message: errors.date } : undefined]} />
|
||||
<FieldDescription>
|
||||
Must fall in an open accounting period. Reports that start after this date show these amounts as opening
|
||||
balances, so use the day you started — or the first day of the financial year.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
{state.accounts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-8 text-center text-base text-muted-foreground">
|
||||
There are no cash or bank accounts yet. Create them under Accounts → Cash / Bank Accounts first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Code</TableHead>
|
||||
<TableHead className="w-56 text-right">Opening balance ({state.baseCurrency})</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{state.accounts.map((a) => {
|
||||
const ok = usable(a)
|
||||
const err = errors[a.glAccountCode]
|
||||
return (
|
||||
<TableRow key={a.glAccountCode} className="align-top">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<AccountIcon type={a.accountType} />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{a.accountName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{a.accountType}
|
||||
{a.bankName ? ` · ${a.bankName}` : ""}
|
||||
{a.accountNumber ? ` · ${a.accountNumber}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">{a.glAccountCode}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{ok ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={amounts[a.glAccountCode] ?? ""}
|
||||
onChange={(e) => {
|
||||
setAmounts((p) => ({ ...p, [a.glAccountCode]: e.target.value }))
|
||||
setErrors((p) => ({ ...p, [a.glAccountCode]: "", lines: "" }))
|
||||
}}
|
||||
placeholder="0.00"
|
||||
aria-label={`Opening balance for ${a.accountName}`}
|
||||
aria-invalid={!!err}
|
||||
className="h-11 text-right text-base"
|
||||
/>
|
||||
<FieldError errors={[err ? { message: err } : undefined]} />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{a.currencyCode} account — not supported here</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
|
||||
<TableCell colSpan={2} className="hidden sm:table-cell">Credited to Opening Balance Equity</TableCell>
|
||||
<TableCell className="sm:hidden">Opening Balance Equity (credit)</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(total)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.lines && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{errors.lines}</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A negative amount records an overdraft (a credit balance). Leave an account blank if it opened at zero.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button size="lg" disabled={posting || state.accounts.length === 0} />}>
|
||||
{posting ? "Posting…" : "Post opening balances"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="Post the opening balances?"
|
||||
description={`This posts one General Ledger journal dated ${date || "—"} totalling ${state.baseCurrency} ${formatAmount(total)}. It can only be done once; any later change needs a correcting journal entry.`}
|
||||
confirmLabel="Post"
|
||||
onConfirm={post}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { ClipboardPaste, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { openingBalancesApi } from "@/lib/api/opening-balances"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
import { OpeningStockRun, OpeningStockRunSummary } from "@/types/opening-balances"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Combobox, ComboboxOption } from "@/components/ui/combobox"
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
const newLine = (patch: Partial<DraftLine> = {}): DraftLine => ({ key: `ol-${++keySeq}`, itemId: null, qty: "", unitCost: "", ...patch })
|
||||
|
||||
/** A positive number with at most `dp` decimals; null otherwise. */
|
||||
function num(raw: string, dp: number, allowZero: boolean): number | null {
|
||||
const text = raw.trim()
|
||||
if (text === "") return null
|
||||
const n = Number(text)
|
||||
if (!Number.isFinite(n) || n < 0 || (!allowZero && n === 0)) return null
|
||||
const f = 10 ** dp
|
||||
return Math.round(n * f) / f === n ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening stock: what is already on the shelf when the system starts. One warehouse per run (load them one at a
|
||||
* time); each line becomes a FIFO layer at the unit cost entered, and ONE journal debits Inventory against
|
||||
* Opening Balance Equity. An item that already has stock movements in the warehouse is refused by the server.
|
||||
* Only stocked, untracked items are offered — serial/batch items need per-unit identities.
|
||||
*/
|
||||
export function OpeningStockPanel() {
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [runs, setRuns] = useState<OpeningStockRunSummary[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [date, setDate] = useState(todayIso())
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<DraftLine[]>([newLine()])
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [posting, setPosting] = useState(false)
|
||||
|
||||
const [pasteOpen, setPasteOpen] = useState(false)
|
||||
const [pasteText, setPasteText] = useState("")
|
||||
const [pasteNotes, setPasteNotes] = useState<string[]>([])
|
||||
|
||||
const [openRun, setOpenRun] = useState<OpeningStockRun | null>(null)
|
||||
const [openRunLoading, setOpenRunLoading] = useState(false)
|
||||
|
||||
function loadRuns() {
|
||||
openingBalancesApi.stockRuns().then(setRuns).catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRuns()
|
||||
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const all: ItemListItem[] = []
|
||||
for (let page = 1; page <= 25; page++) {
|
||||
const res = await itemsApi.list({ status: "Active", page, pageSize: 200 })
|
||||
all.push(...res.items)
|
||||
if (page >= res.pagination.totalPages) break
|
||||
}
|
||||
if (!cancelled) setItems(all)
|
||||
})().catch((err) => {
|
||||
if (!cancelled) {
|
||||
setItems([])
|
||||
toast.error("Could not load items", errorMessage(err))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only what the server accepts: stocked items with no serial/batch tracking.
|
||||
const eligible = useMemo(() => (items ?? []).filter((i) => i.stockNature === "Stocked" && i.trackingMode === "None"), [items])
|
||||
const options = useMemo<ComboboxOption<number>[]>(
|
||||
() => eligible.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })).sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[eligible],
|
||||
)
|
||||
|
||||
const total = useMemo(
|
||||
() =>
|
||||
Math.round(
|
||||
lines.reduce((sum, l) => {
|
||||
const q = num(l.qty, 4, false)
|
||||
const c = num(l.unitCost, 4, true)
|
||||
return sum + (q !== null && c !== null ? q * c : 0)
|
||||
}, 0) * 10000,
|
||||
) / 10000,
|
||||
[lines],
|
||||
)
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
setErrors((prev) => ({ ...prev, [key]: "", general: "" }))
|
||||
}
|
||||
|
||||
/** "SKU qty cost" per row, tab- or comma-separated — what copying three columns out of a spreadsheet gives. */
|
||||
function applyPaste() {
|
||||
const notes: string[] = []
|
||||
const next: DraftLine[] = []
|
||||
const bySku = new Map(eligible.map((i) => [i.sku.trim().toLowerCase(), i]))
|
||||
pasteText.split(/\r?\n/).forEach((row, idx) => {
|
||||
if (!row.trim()) return
|
||||
const cells = row.split(/\t|,/).map((c) => c.trim())
|
||||
if (idx === 0 && Number.isNaN(Number(cells[1]))) return // a header row
|
||||
const [sku, qty, cost] = cells
|
||||
const item = bySku.get((sku ?? "").toLowerCase())
|
||||
if (!item) notes.push(`Row ${idx + 1}: "${sku}" is not an active, stocked, untracked item — skipped.`)
|
||||
else next.push(newLine({ itemId: item.itemId, qty: qty ?? "", unitCost: cost ?? "" }))
|
||||
})
|
||||
if (next.length > 0) setLines((prev) => [...prev.filter((l) => l.itemId !== null || l.qty || l.unitCost), ...next])
|
||||
setPasteNotes(notes)
|
||||
if (notes.length === 0) {
|
||||
setPasteOpen(false)
|
||||
setPasteText("")
|
||||
}
|
||||
toast.success("Rows added", `${next.length} line${next.length === 1 ? "" : "s"} imported${notes.length ? `, ${notes.length} skipped` : ""}.`)
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const next: Record<string, string> = {}
|
||||
const payload: { itemId: number; warehouseId: number; qty: number; unitCost: number }[] = []
|
||||
const used = new Set<number>()
|
||||
for (const l of lines) {
|
||||
if (l.itemId === null && !l.qty && !l.unitCost) continue // an untouched blank row
|
||||
const q = num(l.qty, 4, false)
|
||||
const c = num(l.unitCost, 4, true)
|
||||
if (l.itemId === null) next[l.key] = "Select an item"
|
||||
else if (used.has(l.itemId)) next[l.key] = "This item is already on another line"
|
||||
else if (q === null) next[l.key] = "Quantity must be more than 0 (up to 4 decimals)"
|
||||
else if (c === null) next[l.key] = "Unit cost must be 0 or more (up to 4 decimals)"
|
||||
else {
|
||||
used.add(l.itemId)
|
||||
payload.push({ itemId: l.itemId, warehouseId: warehouseId ?? 0, qty: q, unitCost: c })
|
||||
}
|
||||
}
|
||||
if (warehouseId === null) next.warehouse = "Choose the warehouse"
|
||||
if (!date) next.date = "Choose the opening date"
|
||||
else if (date > todayIso()) next.date = "The opening date cannot be in the future"
|
||||
if (payload.length === 0 && Object.keys(next).length === 0) next.general = "Add at least one item with a quantity"
|
||||
setErrors(next)
|
||||
return { payload, ok: Object.keys(next).length === 0 }
|
||||
}
|
||||
|
||||
async function post() {
|
||||
const { payload, ok } = validate()
|
||||
if (!ok) return
|
||||
setPosting(true)
|
||||
try {
|
||||
const run = await openingBalancesApi.postStock({ cutoverDate: date, lines: payload })
|
||||
toast.success(
|
||||
"Opening stock posted",
|
||||
`${run.lineCount} line${run.lineCount === 1 ? "" : "s"} · ${formatAmount(run.totalValue)}${run.glJournalNo ? ` · ${run.glJournalNo}` : ""}`,
|
||||
)
|
||||
setLines([newLine()])
|
||||
setOpenRun(run)
|
||||
loadRuns()
|
||||
} catch (err) {
|
||||
toast.error("Could not post the opening stock", errorMessage(err))
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function showRun(id: number) {
|
||||
setOpenRunLoading(true)
|
||||
try {
|
||||
setOpenRun(await openingBalancesApi.stockRun(id))
|
||||
} catch (err) {
|
||||
toast.error("Could not load that run", errorMessage(err))
|
||||
} finally {
|
||||
setOpenRunLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
if (items === null || runs === null) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-base text-muted-foreground">
|
||||
Enter the stock you already have on the shelf, one warehouse at a time. Each line becomes stock at the unit cost you give, and one
|
||||
General Ledger journal debits Inventory against <span className="font-medium text-foreground">Opening Balance Equity</span>.
|
||||
An item can be opened once per warehouse — after that, use a stock adjustment.
|
||||
</p>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 rounded-2xl border bg-card p-5 shadow-sm sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.date}>
|
||||
<FieldLabel htmlFor="ostock-date" required>Opening date</FieldLabel>
|
||||
<Input
|
||||
id="ostock-date"
|
||||
type="date"
|
||||
max={todayIso()}
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value)
|
||||
setErrors((p) => ({ ...p, date: "" }))
|
||||
}}
|
||||
aria-invalid={!!errors.date}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.date ? { message: errors.date } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.warehouse}>
|
||||
<FieldLabel required>Warehouse</FieldLabel>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={(v) => {
|
||||
setWarehouseId(v)
|
||||
setErrors((p) => ({ ...p, warehouse: "" }))
|
||||
}}
|
||||
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouse}>
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouse ? { message: errors.warehouse } : undefined]} />
|
||||
<FieldDescription>Quantities are counted in each item's base unit (bottle, can, pack…).</FieldDescription>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-foreground">Items</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setPasteOpen((o) => !o)}>
|
||||
<ClipboardPaste className="size-5" />
|
||||
Paste from spreadsheet
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, newLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pasteOpen && (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-dashed p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste rows of <span className="font-medium text-foreground">SKU, quantity, unit cost</span> (tab- or comma-separated — three columns
|
||||
copied from a spreadsheet work). A header row is skipped.
|
||||
</p>
|
||||
<Textarea
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
placeholder={"CLA-500ML\t240\t95.50\nCLA-1L\t120\t160"}
|
||||
rows={6}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{pasteNotes.length > 0 && (
|
||||
<ul className="list-disc pl-5 text-sm text-destructive">
|
||||
{pasteNotes.slice(0, 8).map((n) => <li key={n}>{n}</li>)}
|
||||
{pasteNotes.length > 8 && <li>…and {pasteNotes.length - 8} more.</li>}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" onClick={applyPaste} disabled={!pasteText.trim()}>Add rows</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => { setPasteOpen(false); setPasteNotes([]) }}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="min-w-[44rem] text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[40%]">Item</TableHead>
|
||||
<TableHead className="w-32 text-right">Quantity</TableHead>
|
||||
<TableHead className="w-36 text-right">Unit cost</TableHead>
|
||||
<TableHead className="w-36 text-right">Value</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((l) => {
|
||||
const q = num(l.qty, 4, false)
|
||||
const c = num(l.unitCost, 4, true)
|
||||
return (
|
||||
<TableRow key={l.key} className="align-top">
|
||||
<TableCell>
|
||||
<Combobox<number>
|
||||
items={options}
|
||||
value={l.itemId}
|
||||
onValueChange={(v) => updateLine(l.key, { itemId: v })}
|
||||
placeholder="Type SKU or name"
|
||||
emptyText="No matching stocked, untracked item."
|
||||
className="h-11 text-base"
|
||||
aria-invalid={!!errors[l.key]}
|
||||
/>
|
||||
<FieldError errors={[errors[l.key] ? { message: errors[l.key] } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number" min="0" step="any" inputMode="decimal" value={l.qty}
|
||||
onChange={(e) => updateLine(l.key, { qty: e.target.value })}
|
||||
aria-label="Quantity" className="h-11 text-right text-base"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number" min="0" step="any" inputMode="decimal" value={l.unitCost}
|
||||
onChange={(e) => updateLine(l.key, { unitCost: e.target.value })}
|
||||
aria-label="Unit cost" className="h-11 text-right text-base"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="pt-3.5 text-right text-muted-foreground">{q !== null && c !== null ? formatAmount(q * c) : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon" aria-label="Remove line"
|
||||
onClick={() => setLines((prev) => (prev.length > 1 ? prev.filter((x) => x.key !== l.key) : [newLine()]))}
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
|
||||
<TableCell colSpan={3}>Debited to Inventory / credited to Opening Balance Equity</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(total)}</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{errors.general && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{errors.general}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button size="lg" disabled={posting} />}>
|
||||
{posting ? "Posting…" : "Post opening stock"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="Post this opening stock?"
|
||||
description={`This adds the stock to ${warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse"} dated ${date || "—"} and posts one journal for ${formatAmount(total)}. An item can't be opened twice in a warehouse, so check the quantities and costs.`}
|
||||
confirmLabel="Post"
|
||||
onConfirm={post}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Posted runs</h2>
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing posted yet.</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>As at</TableHead>
|
||||
<TableHead>Journal</TableHead>
|
||||
<TableHead className="text-right">Lines</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((r) => (
|
||||
<TableRow key={r.openingBalanceRunId}>
|
||||
<TableCell>{formatReportDate(r.cutoverDate)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.glJournalNo ?? "— (zero value)"}</TableCell>
|
||||
<TableCell className="text-right">{r.lineCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(r.totalValue)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button type="button" variant="ghost" size="sm" disabled={openRunLoading} onClick={() => showRun(r.openingBalanceRunId)}>View</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openRun && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Run as at {formatReportDate(openRun.cutoverDate)} · {openRun.glJournalNo ?? "no journal"}
|
||||
</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOpenRun(null)}>Close</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead className="text-right">Qty</TableHead>
|
||||
<TableHead className="text-right">Unit cost</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{openRun.lines.map((l) => (
|
||||
<TableRow key={`${l.itemId}-${l.warehouseId}`}>
|
||||
<TableCell>{l.sku} — {l.itemName}</TableCell>
|
||||
<TableCell>{l.warehouseCode}</TableCell>
|
||||
<TableCell className="text-right">{l.qty}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(l.unitCost)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(l.value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Lock, RefreshCw, ShieldOff } from "lucide-react"
|
||||
|
||||
import { requiredCodesFor } from "@/lib/nav-routes"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
/**
|
||||
* Two doors. First: the page is shown only to people who hold a role. Someone who signs in with a valid login but has no usable role
|
||||
* (none assigned and no default role configured, or their role was deactivated) — or whose account is switched
|
||||
* off — gets a plain explanation instead of a menu full of "Forbidden" screens. "Check again" re-reads their role
|
||||
* from the server, so access an administrator just granted appears without signing out.
|
||||
*
|
||||
* Second: a person with a role still only opens the pages that role grants, even by typing the address
|
||||
* (lib/nav-routes.ts maps each page to its permission). The API enforces the same permissions on every call.
|
||||
*/
|
||||
export function AccessGate({ children }: { children: React.ReactNode }) {
|
||||
const { loading, hasRole, accountBlocked, refresh, can, roleName } = useAuth()
|
||||
const pathname = usePathname() || "/"
|
||||
const [checking, setChecking] = useState(false)
|
||||
|
||||
if (loading) return <>{children}</>
|
||||
|
||||
if (hasRole) {
|
||||
const required = requiredCodesFor(pathname)
|
||||
if (required === null || required.some((code) => can(code))) return <>{children}</>
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ShieldOff className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">You don't have access to this page</h1>
|
||||
<p className="max-w-md text-base text-muted-foreground">
|
||||
{roleName ? `Your role (${roleName}) doesn't include this area.` : "Your role doesn't include this area."} If you need it,
|
||||
ask an administrator to add it under Settings → Roles.
|
||||
</p>
|
||||
<Link href="/dashboard" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to the dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function checkAgain() {
|
||||
setChecking(true)
|
||||
try {
|
||||
await refresh()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-warning/10 text-warning">
|
||||
<Lock className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{accountBlocked ? "Your account is switched off" : "Waiting for access"}
|
||||
</h1>
|
||||
<p className="max-w-md text-base text-muted-foreground">
|
||||
{accountBlocked
|
||||
? "An administrator has deactivated this account. Please contact them if you think this is a mistake."
|
||||
: "You're signed in, but no role has been assigned to your account yet. Ask an administrator to give you one (Settings → Users), then check again."}
|
||||
</p>
|
||||
{!accountBlocked && (
|
||||
<Button variant="outline" size="lg" onClick={checkAgain} disabled={checking}>
|
||||
<RefreshCw className={checking ? "size-5 animate-spin" : "size-5"} />
|
||||
{checking ? "Checking…" : "Check again"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client"
|
||||
|
||||
// Authoritative session context: fetches GET /auth/me once per mount and exposes
|
||||
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
|
||||
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
|
||||
// (that cache remains display-only, e.g. for the header's user name).
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
// Authoritative session context: fetches GET /auth/me and exposes the current role and what it may do.
|
||||
// The role lives in the ERP (users.RoleId) and is read on every request, so this is refetched whenever the
|
||||
// window regains focus (and on demand via `refresh`) — a role or permission change made by an administrator
|
||||
// shows up without signing out. Replaces trusting the stale, client-only `roleId` cached by
|
||||
// lib/auth-session.ts for anything access-related (that cache remains display-only, e.g. the header's name).
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
import { ApiError, FORBIDDEN_EVENT } from "@/lib/api-client"
|
||||
import { MeResponse, RolePermissionAction } from "@/types/rbac"
|
||||
|
||||
interface ActiveBranch {
|
||||
id: number | null
|
||||
@@ -19,6 +21,9 @@ interface ActiveBranch {
|
||||
* display/filter convenience — never trusted server-side. */
|
||||
const ACTIVE_BRANCH_KEY = "erpcore.activeBranch"
|
||||
|
||||
/** Don't re-ask the server more often than this when the window keeps regaining focus. */
|
||||
const FOCUS_REFRESH_MIN_MS = 15_000
|
||||
|
||||
function readStoredActiveBranch(): ActiveBranch | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const raw = window.localStorage.getItem(ACTIVE_BRANCH_KEY)
|
||||
@@ -31,12 +36,28 @@ function readStoredActiveBranch(): ActiveBranch | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the actions a permission can grant (Approve, Delete, …). */
|
||||
export type PermissionActionName = keyof typeof RolePermissionAction
|
||||
|
||||
interface AuthContextValue {
|
||||
roleId: number | null
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
/** True for the Admin role — the only role that may pick any branch (others are locked to their own). */
|
||||
/** True when the person has a usable role. False means "waiting for access": no role assigned and no default, or an inactive role. */
|
||||
hasRole: boolean
|
||||
/** True for the administrator role — the only role that may pick any branch (others are locked to their own). */
|
||||
isAdmin: boolean
|
||||
/**
|
||||
* Whether the role may reach `navCode` (a module, or a screen within one) and — when `action` is given — holds that
|
||||
* action on exactly that code. Administrators may do everything. Hide or disable UI with this; the server
|
||||
* enforces the same rules, so this is a convenience, never the protection.
|
||||
*/
|
||||
can: (navCode: string, action?: PermissionActionName) => boolean
|
||||
/** Re-reads the role and permissions from the server (after changing your own role, say). */
|
||||
refresh: () => Promise<void>
|
||||
/** True when the account itself is switched off (the server answered 403 to /auth/me). */
|
||||
accountBlocked: boolean
|
||||
/** The caller's own branch, via their linked Employee record — null if unlinked. */
|
||||
branchId: number | null
|
||||
branchName: string | null
|
||||
@@ -67,10 +88,15 @@ interface AuthContextValue {
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
roleId: null,
|
||||
roleCode: null,
|
||||
roleName: null,
|
||||
navCodes: [],
|
||||
hasRole: false,
|
||||
isAdmin: false,
|
||||
can: () => false,
|
||||
refresh: async () => {},
|
||||
accountBlocked: false,
|
||||
branchId: null,
|
||||
branchName: null,
|
||||
activeBranchId: null,
|
||||
@@ -81,27 +107,50 @@ const AuthContext = createContext<AuthContextValue>({
|
||||
loading: true,
|
||||
})
|
||||
|
||||
const NO_ACCESS: MeResponse = {
|
||||
roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null,
|
||||
branchingEnabled: true, warehouseBranchingEnabled: true, roleId: null, hasRole: false, permissions: {},
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [accountBlocked, setAccountBlocked] = useState(false)
|
||||
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
|
||||
const lastFetch = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
authApi
|
||||
.me()
|
||||
.then((res) => {
|
||||
if (!cancelled) setMe(res)
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
const load = useCallback(async () => {
|
||||
lastFetch.current = Date.now()
|
||||
try {
|
||||
const res = await authApi.me()
|
||||
setMe(res)
|
||||
setAccountBlocked(false)
|
||||
} catch (err) {
|
||||
// 403 = the ERP account is switched off; anything else (unauthenticated/unreachable) falls back to
|
||||
// "no permissions" rather than crashing the shell — proxy.ts already redirects unauthenticated users to /login.
|
||||
setAccountBlocked(err instanceof ApiError && err.status === 403)
|
||||
setMe((prev) => prev ?? NO_ACCESS)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// The role is read from the ERP on every request, so keep the UI in step with it: refetch when the person comes
|
||||
// back to the tab (rate-limited), which picks up a role or permission change an administrator just made.
|
||||
useEffect(() => {
|
||||
function onFocus() {
|
||||
if (Date.now() - lastFetch.current >= FOCUS_REFRESH_MIN_MS) void load()
|
||||
}
|
||||
// A 403 from any call means what this tab believes the role can do may be stale: re-read it (same rate limit).
|
||||
window.addEventListener("focus", onFocus)
|
||||
window.addEventListener(FORBIDDEN_EVENT, onFocus)
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus)
|
||||
window.removeEventListener(FORBIDDEN_EVENT, onFocus)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (me === null) return
|
||||
// Non-admins are always pinned to their own branch — never their own past picks.
|
||||
@@ -115,13 +164,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
const can = useCallback(
|
||||
(navCode: string, action?: PermissionActionName): boolean => {
|
||||
if (!me) return false
|
||||
if (me.isAdmin) return true
|
||||
const permissions = me.permissions ?? {}
|
||||
if (action) {
|
||||
const granted = permissions[navCode]
|
||||
return granted !== undefined && (granted & RolePermissionAction[action]) !== 0
|
||||
}
|
||||
// Reaching a module: the code itself, or any screen inside it ("procurement" matches "procurement.vendors").
|
||||
return navCode in permissions || Object.keys(permissions).some((c) => c.startsWith(navCode + "."))
|
||||
},
|
||||
[me],
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
roleId: me?.roleId ?? null,
|
||||
roleCode: me?.roleCode ?? null,
|
||||
roleName: me?.roleName ?? null,
|
||||
navCodes: me?.navCodes ?? [],
|
||||
hasRole: me?.hasRole ?? false,
|
||||
isAdmin: me?.isAdmin ?? false,
|
||||
can,
|
||||
refresh: load,
|
||||
accountBlocked,
|
||||
branchId: me?.branchId ?? null,
|
||||
branchName: me?.branchName ?? null,
|
||||
activeBranchId: activeBranch?.id ?? null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { useCustomerConfig } from "@/hooks/use-customer-config"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { Customer, CustomerType } from "@/types/customers"
|
||||
@@ -28,6 +29,8 @@ export function EditCustomerDialog({
|
||||
onClose: () => void
|
||||
onSaved: (customer: Customer) => void
|
||||
}) {
|
||||
// A field switched off in Sales Configuration is hidden; its stored value is sent back unchanged.
|
||||
const { fields } = useCustomerConfig()
|
||||
const [customer, setCustomer] = useState<Customer | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -194,48 +197,70 @@ export function EditCustomerDialog({
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
|
||||
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
|
||||
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
|
||||
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
|
||||
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
|
||||
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{(fields.phone || fields.email) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{fields.phone && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||
</Field>
|
||||
)}
|
||||
{fields.email && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
|
||||
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fields.address && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
|
||||
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
|
||||
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{(fields.city || fields.country) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{fields.city && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
|
||||
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{fields.country && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
|
||||
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-tax">Tax registration (optional)</FieldLabel>
|
||||
<Input id="ec-tax" value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
|
||||
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{(fields.creditLimit || fields.creditDays) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{fields.creditLimit && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{fields.creditDays && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
|
||||
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
|
||||
import { ProductConfig, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
/**
|
||||
* Warranty choice for an existing item: Non-Warranty / Warranty plus a period. Same options as the New Item
|
||||
* form — the default periods, the saved custom periods (when custom periods are on), and a one-off
|
||||
* "Custom period" typed here that applies to this item only and is never added to the Warranty Periods master.
|
||||
* The server validates the period again (ItemService.ValidateWarrantyPeriod).
|
||||
*/
|
||||
export function WarrantyFields({
|
||||
config,
|
||||
customMonths,
|
||||
warranty,
|
||||
months,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: {
|
||||
config: ProductConfig
|
||||
/** Active rows of the Warranty Periods master, in months. */
|
||||
customMonths: number[]
|
||||
warranty: Warranty
|
||||
months: WarrantyPeriodMonths | null
|
||||
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const groups = warrantyPeriodGroups(config, customMonths)
|
||||
// The item's own length when it is not one of the offered periods (a one-off custom period saved earlier).
|
||||
const offered = [...groups.defaults, ...groups.custom]
|
||||
const ownLength = months !== null && !offered.includes(months) ? months : null
|
||||
const selectGroups = [
|
||||
{ label: "Default periods", months: groups.defaults },
|
||||
{ label: "Custom periods", months: groups.custom },
|
||||
{ label: "This item only", months: ownLength !== null ? [ownLength] : [] },
|
||||
].filter((g) => g.months.length > 0)
|
||||
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [amount, setAmount] = useState("")
|
||||
const [unit, setUnit] = useState<WarrantyInputUnit>("months")
|
||||
const [customError, setCustomError] = useState<string | null>(null)
|
||||
|
||||
function closeCustom() {
|
||||
setCustomOpen(false)
|
||||
setAmount("")
|
||||
setUnit("months")
|
||||
setCustomError(null)
|
||||
}
|
||||
|
||||
function applyCustom() {
|
||||
const parsed = parseWarrantyInput(amount, unit, customMonths)
|
||||
if (parsed.error !== undefined) {
|
||||
setCustomError(parsed.error)
|
||||
return
|
||||
}
|
||||
onChange("Warranty", parsed.months as WarrantyPeriodMonths)
|
||||
closeCustom()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-base">Warranty</Label>
|
||||
<RadioGroup
|
||||
value={warranty}
|
||||
onValueChange={(v) => {
|
||||
const next = v as Warranty
|
||||
onChange(next, next === "NonWarranty" ? null : months)
|
||||
}}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:gap-6"
|
||||
>
|
||||
<label className="flex items-center gap-2.5">
|
||||
<RadioGroupItem value="NonWarranty" disabled={disabled} />
|
||||
<span className="text-base font-medium">Non-Warranty</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5">
|
||||
<RadioGroupItem value="Warranty" disabled={disabled} />
|
||||
<span className="text-base font-medium">Warranty</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
|
||||
{warranty === "Warranty" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label required>Warranty period</Label>
|
||||
<Select<WarrantyPeriodMonths>
|
||||
value={months}
|
||||
onValueChange={(v) => v && onChange("Warranty", v)}
|
||||
items={selectGroups.flatMap((g) => g.months).map((m) => ({ label: formatWarrantyPeriod(m), value: m as WarrantyPeriodMonths }))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base sm:w-60" aria-invalid={!!error}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectGroups.map((group, i) => (
|
||||
<Fragment key={group.label}>
|
||||
{i > 0 && <SelectSeparator />}
|
||||
<SelectGroup>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.months.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-base">
|
||||
{formatWarrantyPeriod(m)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[error ? { message: error } : undefined]} />
|
||||
|
||||
{config.customWarrantyPeriodsEnabled &&
|
||||
(customOpen ? (
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For this item only — it isn't added to Warranty Periods or offered on other items.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
autoFocus
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value)
|
||||
setCustomError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
applyCustom()
|
||||
} else if (e.key === "Escape") closeCustom()
|
||||
}}
|
||||
placeholder="e.g. 2"
|
||||
aria-label="Warranty length"
|
||||
aria-invalid={!!customError}
|
||||
className="h-10 w-24 text-base"
|
||||
/>
|
||||
<Select<WarrantyInputUnit>
|
||||
value={unit}
|
||||
onValueChange={(v) => v && setUnit(v)}
|
||||
items={[
|
||||
{ label: "Months", value: "months" },
|
||||
{ label: "Years", value: "years" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-28 text-base" aria-label="Unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="months" className="text-base">Months</SelectItem>
|
||||
<SelectItem value="years" className="text-base">Years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" size="sm" onClick={applyCustom}>Use</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={closeCustom}>Cancel</Button>
|
||||
</div>
|
||||
{customError && <p className="text-sm text-destructive">{customError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="link" size="sm" className="h-auto w-fit px-0" onClick={() => setCustomOpen(true)} disabled={disabled}>
|
||||
<Plus className="size-3.5" />
|
||||
Custom period
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
A4_SHEET_CLASS,
|
||||
ACCENT,
|
||||
formatDate,
|
||||
InvoicePreviewFrame,
|
||||
money,
|
||||
PaperSize,
|
||||
printColorExact,
|
||||
qty,
|
||||
RECEIPT_SHEET_BASE_CLASS,
|
||||
RECEIPT_SHEET_CLASS,
|
||||
ReceiptRow,
|
||||
ReceiptRule,
|
||||
ReceiptWidth,
|
||||
SectionTitle,
|
||||
} from "@/components/invoice-template/InvoicePrintKit"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Grn } from "@/types/grn"
|
||||
import { Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
/*
|
||||
* Printable goods-received note in the same three paper sizes as the invoices — A4, 80mm and 58mm receipt — sharing
|
||||
* their preview frame, PDF export and print plumbing. A draft prints with a DRAFT marker so it can't be mistaken for a
|
||||
* posted receipt.
|
||||
*/
|
||||
|
||||
interface GrnDoc {
|
||||
grn: Grn
|
||||
vendor: Vendor | null
|
||||
warehouse: Warehouse | null
|
||||
poDocNo: string | null
|
||||
company: CompanyProfile | null
|
||||
showLogo: boolean
|
||||
/** itemId -> "SKU — name" */
|
||||
itemLabels: Map<number, { sku: string; name: string }>
|
||||
}
|
||||
|
||||
const statusMark = (grn: Grn) => (grn.status === "Draft" ? "DRAFT" : null)
|
||||
|
||||
function A4Sheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels }: GrnDoc) {
|
||||
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
|
||||
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
|
||||
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
|
||||
return (
|
||||
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">GOODS RECEIVED NOTE</h1>
|
||||
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
|
||||
<dt className="text-xs font-bold text-neutral-900">GRN No.</dt>
|
||||
<dd>{grn.docNo}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Date</dt>
|
||||
<dd>{formatDate(grn.postedAt ?? grn.createdAt)}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Against</dt>
|
||||
<dd>{poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct receipt"}</dd>
|
||||
{statusMark(grn) && (
|
||||
<>
|
||||
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||
<dd className="font-semibold" style={{ color: ACCENT }}>{statusMark(grn)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 mb-7">
|
||||
<div className="h-1 bg-neutral-900" />
|
||||
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<SectionTitle>Received from</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
|
||||
{vendor?.code && <p>Vendor code: {vendor.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SectionTitle>Received at</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
{company && <p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>}
|
||||
<p>{warehouse ? `${warehouse.code} — ${warehouse.name}` : `Warehouse #${grn.warehouseId}`}</p>
|
||||
{warehouse?.address && <p>{warehouse.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<SectionTitle>Goods Received</SectionTitle>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
|
||||
<th className="px-2 py-2.5 text-left">Item</th>
|
||||
<th className="px-2 py-2.5 text-right">Qty</th>
|
||||
<th className="px-2 py-2.5 text-right">Unit cost</th>
|
||||
<th className="px-2 py-2.5 text-right">Disc %</th>
|
||||
<th className="px-2 py-2.5 text-right">VAT</th>
|
||||
<th className="px-2 py-2.5 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grn.lines.map((l) => {
|
||||
const item = itemLabels.get(l.itemId)
|
||||
return (
|
||||
<tr key={l.grnLineId} className="border-b border-neutral-300">
|
||||
<td className="px-2 py-2.5">
|
||||
<p className="font-medium text-neutral-900">{item?.name ?? `Item #${l.itemId}`}</p>
|
||||
{item?.sku && <p className="text-xs text-neutral-500">{item.sku}</p>}
|
||||
{l.batchNo && <p className="text-xs text-neutral-500">Batch {l.batchNo}</p>}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{qty(l.qty)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{money(l.unitCost)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{l.discountPct > 0 ? l.discountPct : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{l.vatAmount > 0 ? money(l.vatAmount) : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(l.lineTotal)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end print:break-inside-avoid">
|
||||
<div className="w-72 border border-neutral-900">
|
||||
<div className="space-y-1.5 px-3.5 py-3 text-xs">
|
||||
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(subtotal)}</span></div>
|
||||
{vat > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(vat)}</span></div>}
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
|
||||
<span>Total</span>
|
||||
<span className="tabular-nums">{money(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-2 gap-16 text-xs text-neutral-700 print:break-inside-avoid">
|
||||
<div className="border-t border-neutral-900 pt-1.5">Received by</div>
|
||||
<div className="border-t border-neutral-900 pt-1.5">Delivered by</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 80mm / 58mm thermal layout: one centred column, no fills. */
|
||||
function ReceiptSheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels, width }: GrnDoc & { width: ReceiptWidth }) {
|
||||
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
|
||||
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
|
||||
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
|
||||
return (
|
||||
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
|
||||
<div className="text-center">
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
|
||||
)}
|
||||
{company && <p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>}
|
||||
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">GOODS RECEIVED</p>
|
||||
{statusMark(grn) && <p className="font-bold">*** {statusMark(grn)} ***</p>}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="GRN No." value={grn.docNo} />
|
||||
<ReceiptRow label="Date" value={formatDate(grn.postedAt ?? grn.createdAt)} />
|
||||
<ReceiptRow label="Against" value={poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct"} />
|
||||
<ReceiptRow label="Vendor" value={vendor?.name ?? `#${grn.vendorId}`} />
|
||||
<ReceiptRow label="Warehouse" value={warehouse?.code ?? `#${grn.warehouseId}`} />
|
||||
|
||||
<ReceiptRule />
|
||||
<div className="space-y-1.5">
|
||||
{grn.lines.map((l) => {
|
||||
const item = itemLabels.get(l.itemId)
|
||||
return (
|
||||
<div key={l.grnLineId}>
|
||||
<p className="font-medium">{item?.name ?? `Item #${l.itemId}`}</p>
|
||||
<div className="flex justify-between gap-2 tabular-nums">
|
||||
<span>{qty(l.qty)} x {money(l.unitCost)}</span>
|
||||
<span>{money(l.lineTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Subtotal" value={money(subtotal)} />
|
||||
{vat > 0 && <ReceiptRow label="VAT" value={money(vat)} />}
|
||||
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||
<ReceiptRow label="TOTAL" value={money(total)} bold />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the GRN plus the optional decoration around it (vendor, warehouse, PO, item names, company). Only the GRN is
|
||||
* required — any other failure just leaves that block out. */
|
||||
function useGrnDoc(grnId: number) {
|
||||
const [doc, setDoc] = useState<GrnDoc | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(grnId) ? loadError : `Invalid GRN id '${grnId}'.`
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(grnId)) return
|
||||
let cancelled = false
|
||||
grnsApi
|
||||
.get(grnId)
|
||||
.then(async (grn) => {
|
||||
const itemIds = [...new Set(grn.lines.map((l) => l.itemId))]
|
||||
const [vendorRes, warehouse, po, companyRes, templateRes, items] = await Promise.all([
|
||||
vendorsApi.get(grn.vendorId).catch(() => null),
|
||||
warehousesApi.get(grn.warehouseId).catch(() => null),
|
||||
grn.poId ? purchaseOrdersApi.get(grn.poId).catch(() => null) : Promise.resolve(null),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
Promise.all(itemIds.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null))),
|
||||
])
|
||||
if (cancelled) return
|
||||
const company = companyRes.data
|
||||
const itemLabels = new Map<number, { sku: string; name: string }>()
|
||||
for (const item of items) if (item) itemLabels.set(item.itemId, { sku: item.sku, name: item.name })
|
||||
setDoc({
|
||||
grn,
|
||||
vendor: vendorRes?.data ?? null,
|
||||
warehouse,
|
||||
poDocNo: po?.docNo ?? null,
|
||||
company,
|
||||
showLogo: templateRes.data?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||
itemLabels,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [grnId])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: GrnDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||
}
|
||||
|
||||
/** Popup print preview of a GRN — same frame as the invoice dialogs. Opens on A4 every time. */
|
||||
export function GrnReceiptDialog({
|
||||
grnId,
|
||||
docNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
grnId: number | null
|
||||
docNo?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||
<div className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-lg font-semibold">Goods received note{docNo ? ` ${docNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
</div>
|
||||
{open && grnId !== null && <GrnReceiptPreview grnId={grnId} className="flex-1" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function GrnReceiptPreview({ grnId, className }: { grnId: number; className?: string }) {
|
||||
const { doc, error } = useGrnDoc(grnId)
|
||||
return (
|
||||
<InvoicePreviewFrame
|
||||
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||
error={error}
|
||||
docNo={doc?.grn.docNo ?? `grn-${grnId}`}
|
||||
shareTitle={`GRN ${doc?.grn.docNo ?? ""}`.trim()}
|
||||
sharePath={`/dashboard/receiving/grn/${grnId}`}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarDays } from "lucide-react"
|
||||
|
||||
import { DateRangePicker } from "@/components/ui/date-picker"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
export type DateRangePreset = "All" | "Today" | "7d" | "30d" | "Month" | "Custom"
|
||||
export type CustomDateRange = { from: Date | undefined; to?: Date | undefined }
|
||||
|
||||
export interface DateRangeValue {
|
||||
preset: DateRangePreset
|
||||
custom?: CustomDateRange
|
||||
}
|
||||
|
||||
const PRESETS: { value: DateRangePreset; label: string }[] = [
|
||||
{ value: "All", label: "All time" },
|
||||
{ value: "Today", label: "Today" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
{ value: "30d", label: "Last 30 days" },
|
||||
{ value: "Month", label: "This month" },
|
||||
{ value: "Custom", label: "Custom range" },
|
||||
]
|
||||
|
||||
export function toIsoDate(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Inclusive `YYYY-MM-DD` bounds for a filter value; empty (no bounds) for "All time" or a custom range with no start yet. */
|
||||
export function dateRangeBounds(value: DateRangeValue): { from?: string; to?: string } {
|
||||
const today = new Date()
|
||||
switch (value.preset) {
|
||||
case "All":
|
||||
return {}
|
||||
case "Custom":
|
||||
return value.custom?.from ? { from: toIsoDate(value.custom.from), to: toIsoDate(value.custom.to ?? value.custom.from) } : {}
|
||||
case "Month":
|
||||
return { from: toIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to: toIsoDate(today) }
|
||||
default: {
|
||||
const days = value.preset === "Today" ? 1 : value.preset === "7d" ? 7 : 30
|
||||
const from = new Date(today)
|
||||
from.setDate(today.getDate() - (days - 1))
|
||||
return { from: toIsoDate(from), to: toIsoDate(today) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A date-range dropdown (presets, plus a from–to picker for "Custom range") for list-page filter bars. */
|
||||
export function DateRangeFilter({
|
||||
value,
|
||||
onChange,
|
||||
label = "Date range",
|
||||
}: {
|
||||
value: DateRangeValue
|
||||
onChange: (next: DateRangeValue) => void
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Select<DateRangePreset> value={value.preset} onValueChange={(v) => onChange({ ...value, preset: v ?? "All" })}>
|
||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label={label}>
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESETS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{value.preset === "Custom" && (
|
||||
<DateRangePicker
|
||||
value={value.custom}
|
||||
onChange={(custom) => onChange({ ...value, custom })}
|
||||
placeholder="Pick from – to dates"
|
||||
className="sm:w-72"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+36
-9
@@ -6,6 +6,15 @@ import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -40,6 +49,8 @@ export function EditVendorDialog({
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [currency, setCurrency] = useState("")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -51,6 +62,7 @@ export function EditVendorDialog({
|
||||
setTerms(data.terms ?? "")
|
||||
setTaxReg(data.taxReg ?? "")
|
||||
setCurrency(data.currency)
|
||||
setOptional({ mobile: data.mobile ?? "", email: data.email ?? "", address: data.address ?? "" })
|
||||
}
|
||||
|
||||
function reload() {
|
||||
@@ -85,6 +97,7 @@ export function EditVendorDialog({
|
||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
toast.error("Could not save vendor", Object.values(nextErrors)[0])
|
||||
@@ -96,7 +109,7 @@ export function EditVendorDialog({
|
||||
try {
|
||||
const result = await vendorsApi.update(
|
||||
vendorId,
|
||||
{ code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId },
|
||||
{ code, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: vendor.branchId },
|
||||
etag
|
||||
)
|
||||
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
|
||||
@@ -145,14 +158,28 @@ export function EditVendorDialog({
|
||||
<Input id="ev-code" value={code} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
)}
|
||||
{procConfig.vendorTaxRegEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
)}
|
||||
<VendorOptionalFields
|
||||
config={procConfig}
|
||||
values={optional}
|
||||
onChange={(patch) => {
|
||||
setOptional((prev) => ({ ...prev, ...patch }))
|
||||
clearErrors("mobile", "email")
|
||||
}}
|
||||
idPrefix="ev"
|
||||
errors={errors}
|
||||
/>
|
||||
<Field data-invalid={!!errors.currency}>
|
||||
<FieldLabel htmlFor="ev-currency" required>Currency</FieldLabel>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ProcurementConfig } from "@/types/procurement"
|
||||
|
||||
/** The optional vendor details; `""` means "not provided". */
|
||||
export interface VendorOptionalValues {
|
||||
mobile: string
|
||||
email: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export const EMPTY_VENDOR_OPTIONAL: VendorOptionalValues = { mobile: "", email: "", address: "" }
|
||||
|
||||
// Mirrors VendorService.ValidateOptionalFields on the server.
|
||||
const MOBILE_PATTERN = /^\+?[0-9][0-9 \-()]{5,18}$/
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/** Format errors for the fields that are switched on. Blank is always fine — every field is optional. */
|
||||
export function validateVendorOptional(config: ProcurementConfig, v: VendorOptionalValues): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (config.vendorContactMode === "Mobile" && v.mobile.trim() && !MOBILE_PATTERN.test(v.mobile.trim()))
|
||||
errors.mobile = "Enter a valid mobile number (digits, optional leading +)"
|
||||
if (config.vendorContactMode === "Email" && v.email.trim() && !EMAIL_PATTERN.test(v.email.trim()))
|
||||
errors.email = "Enter a valid email address"
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* The request fields for the optional details. Only fields that are switched on are included, so a
|
||||
* hidden field is never sent (the server answers 422 CONFIG_DISABLED to one) and a blank becomes null.
|
||||
*/
|
||||
export function vendorOptionalPayload(config: ProcurementConfig, v: VendorOptionalValues) {
|
||||
return {
|
||||
...(config.vendorContactMode === "Mobile" ? { mobile: v.mobile.trim() || null } : {}),
|
||||
...(config.vendorContactMode === "Email" ? { email: v.email.trim() || null } : {}),
|
||||
...(config.vendorAddressEnabled ? { address: v.address.trim() || null } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Payment terms / tax registration, included only while their switches are on (a blank becomes null). */
|
||||
export function vendorTermsTaxPayload(config: ProcurementConfig, terms: string, taxReg: string) {
|
||||
return {
|
||||
...(config.vendorPaymentTermsEnabled ? { terms: terms.trim() || null } : {}),
|
||||
...(config.vendorTaxRegEnabled ? { taxReg: taxReg.trim() || null } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile or Email (whichever Procurement configuration selects) and the company address
|
||||
* (when enabled). Renders nothing when both are off, so every vendor form can include it unconditionally.
|
||||
*/
|
||||
export function VendorOptionalFields({
|
||||
config,
|
||||
values,
|
||||
onChange,
|
||||
idPrefix,
|
||||
errors = {},
|
||||
}: {
|
||||
config: ProcurementConfig
|
||||
values: VendorOptionalValues
|
||||
onChange: (patch: Partial<VendorOptionalValues>) => void
|
||||
idPrefix: string
|
||||
errors?: Record<string, string>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{config.vendorContactMode === "Mobile" && (
|
||||
<Field data-invalid={!!errors.mobile}>
|
||||
<FieldLabel htmlFor={`${idPrefix}-mobile`}>Mobile number (optional)</FieldLabel>
|
||||
<Input
|
||||
id={`${idPrefix}-mobile`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
value={values.mobile}
|
||||
onChange={(e) => onChange({ mobile: e.target.value })}
|
||||
placeholder="+94 77 123 4567"
|
||||
maxLength={20}
|
||||
aria-invalid={!!errors.mobile}
|
||||
/>
|
||||
<FieldError errors={[errors.mobile ? { message: errors.mobile } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
{config.vendorContactMode === "Email" && (
|
||||
<Field data-invalid={!!errors.email}>
|
||||
<FieldLabel htmlFor={`${idPrefix}-email`}>Email (optional)</FieldLabel>
|
||||
<Input
|
||||
id={`${idPrefix}-email`}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
value={values.email}
|
||||
onChange={(e) => onChange({ email: e.target.value })}
|
||||
placeholder="accounts@supplier.com"
|
||||
maxLength={200}
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
{config.vendorAddressEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${idPrefix}-address`}>Company address (optional)</FieldLabel>
|
||||
<Textarea
|
||||
id={`${idPrefix}-address`}
|
||||
value={values.address}
|
||||
onChange={(e) => onChange({ address: e.target.value })}
|
||||
placeholder="No. 12, Galle Road, Colombo 03"
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
|
||||
/** Which optional customer fields Settings -> Sales Configuration has switched on. */
|
||||
export interface CustomerFieldConfig {
|
||||
phone: boolean
|
||||
email: boolean
|
||||
address: boolean
|
||||
city: boolean
|
||||
country: boolean
|
||||
creditLimit: boolean
|
||||
creditDays: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything on — the default before this became configurable, and what a screen shows until the real switches arrive
|
||||
* (or if they can't be read). The server refuses a value for a field that is off, so a wrong guess is caught there.
|
||||
*/
|
||||
export const CUSTOMER_FIELDS_ON: CustomerFieldConfig = {
|
||||
phone: true,
|
||||
email: true,
|
||||
address: true,
|
||||
city: true,
|
||||
country: true,
|
||||
creditLimit: true,
|
||||
creditDays: true,
|
||||
}
|
||||
|
||||
/** Loads the optional-customer-field switches from the Sales configuration. */
|
||||
export function useCustomerConfig(): { fields: CustomerFieldConfig; loaded: boolean } {
|
||||
const [fields, setFields] = useState<CustomerFieldConfig>(CUSTOMER_FIELDS_ON)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
salesConfigApi
|
||||
.get()
|
||||
.then(({ data }) => {
|
||||
if (!active) return
|
||||
setFields({
|
||||
phone: data.customerPhoneEnabled,
|
||||
email: data.customerEmailEnabled,
|
||||
address: data.customerAddressEnabled,
|
||||
city: data.customerCityEnabled,
|
||||
country: data.customerCountryEnabled,
|
||||
creditLimit: data.customerCreditLimitEnabled,
|
||||
creditDays: data.customerCreditDaysEnabled,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (active) setLoaded(true)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { fields, loaded }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { procurementConfig } from "@/lib/api/procurement-config"
|
||||
import { ProcurementConfig } from "@/types/procurement"
|
||||
|
||||
/** Everything off — what a screen assumes until the real flags arrive (or if they cannot be read). */
|
||||
export const PROCUREMENT_CONFIG_OFF: ProcurementConfig = {
|
||||
vendorContactMode: "None",
|
||||
vendorAddressEnabled: false,
|
||||
vendorPaymentTermsEnabled: false,
|
||||
vendorTaxRegEnabled: false,
|
||||
poTaxEnabled: false,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the Procurement configuration flags. A failed read leaves every optional field hidden
|
||||
* rather than blocking the screen — the server rejects gated values on its own anyway.
|
||||
*/
|
||||
export function useProcurementConfig(): { config: ProcurementConfig; loaded: boolean } {
|
||||
const [config, setConfig] = useState<ProcurementConfig>(PROCUREMENT_CONFIG_OFF)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
procurementConfig()
|
||||
.then((c) => {
|
||||
if (active) setConfig(c)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (active) setLoaded(true)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { config, loaded }
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { ApiResult, ProblemDetails } from "@/types/common"
|
||||
|
||||
const API_BASE = "/api/v1"
|
||||
|
||||
/** Fired on window whenever the API answers 403, so the auth context can re-read the person's permissions. */
|
||||
export const FORBIDDEN_EVENT = "erp:forbidden"
|
||||
|
||||
/** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
@@ -36,7 +39,7 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
ifMatch?: string
|
||||
/** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */
|
||||
idempotencyKey?: string
|
||||
/** Sent as X-XSRF-TOKEN. Only the [ValidateCsrf] actions on AuthController need it (docs/11 §2.0). */
|
||||
/** Sent as X-XSRF-TOKEN. Now sent automatically on every POST/PUT/PATCH/DELETE (the API checks all of them); kept so a caller can force it. */
|
||||
csrf?: boolean
|
||||
}
|
||||
|
||||
@@ -78,7 +81,11 @@ function redirectToLogin() {
|
||||
|
||||
async function rawRequest(path: string, options: RequestOptions = {}, isRetry = false): Promise<Response> {
|
||||
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
|
||||
const csrfToken = csrf ? readCsrfToken() : null
|
||||
// The API double-submit-checks every state-changing request that rides on the session cookie, so send the token
|
||||
// on all of them (it is just echoing a cookie of our own origin).
|
||||
const method = (rest.method ?? "GET").toUpperCase()
|
||||
const stateChanging = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"
|
||||
const csrfToken = csrf || stateChanging ? readCsrfToken() : null
|
||||
// Multipart uploads (attendance files, staff documents) pass a FormData body — the
|
||||
// browser sets its own Content-Type (with boundary), and it must never be JSON-encoded.
|
||||
const isFormData = typeof FormData !== "undefined" && body instanceof FormData
|
||||
@@ -105,7 +112,18 @@ async function rawRequest(path: string, options: RequestOptions = {}, isRetry =
|
||||
redirectToLogin()
|
||||
}
|
||||
|
||||
// A session older than the CSRF check has no XSRF-TOKEN cookie (or it rotated): renewing the session re-issues it.
|
||||
// Peek at a clone so the normal error path below still reads the body.
|
||||
if (response.status === 403 && !isRetry && !path.startsWith("/auth/")) {
|
||||
const peek = await response.clone().json().catch(() => null) as ProblemDetails | null
|
||||
if (peek?.code === "CSRF_TOKEN_MISMATCH" && (await refreshSession())) return rawRequest(path, options, true)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403 && typeof window !== "undefined") {
|
||||
// The role may have changed under this tab: let the auth context re-read what the person can still do.
|
||||
window.dispatchEvent(new CustomEvent(FORBIDDEN_EVENT))
|
||||
}
|
||||
let problem: ProblemDetails
|
||||
try {
|
||||
problem = (await response.json()) as ProblemDetails
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Access configuration. Singleton - no id. The default role is what a first sign-in (or a person with no role) receives.
|
||||
import { apiRequestWithETag } from "@/lib/api-client"
|
||||
import { ApiResult } from "@/types/common"
|
||||
|
||||
export interface AccessConfig {
|
||||
defaultRoleId: number | null
|
||||
defaultRoleName: string | null
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
|
||||
export const accessConfigApi = {
|
||||
get(): Promise<ApiResult<AccessConfig>> {
|
||||
return apiRequestWithETag<AccessConfig>("/access-config")
|
||||
},
|
||||
|
||||
/** `null` clears the default (people without a role then have no permissions at all). The administrator role is refused. */
|
||||
update(defaultRoleId: number | null, ifMatch: string): Promise<ApiResult<AccessConfig>> {
|
||||
return apiRequestWithETag<AccessConfig>("/access-config", { method: "PUT", body: { defaultRoleId }, ifMatch })
|
||||
},
|
||||
}
|
||||
@@ -27,6 +27,11 @@ export interface ListGrnsParams {
|
||||
vendorId?: number
|
||||
warehouseId?: number
|
||||
branchId?: number
|
||||
/** Created on or after this day (YYYY-MM-DD). */
|
||||
from?: string
|
||||
/** Created on or before this day (YYYY-MM-DD). */
|
||||
to?: string
|
||||
/** `created` (default, newest first) | `vendor` | `total` | `status`; a leading `-` flips the direction. */
|
||||
sort?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Opening balances. post() creates ONE journal in the General Ledger (a debit per cash/bank account, the
|
||||
// offsetting credit to "Opening Balance Equity") and can be done only once: a second call is 409
|
||||
// OPENING_BALANCE_ALREADY_POSTED.
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
import {
|
||||
CashBankOpeningState,
|
||||
OpeningBalanceRun,
|
||||
OpeningStockRun,
|
||||
OpeningStockRunSummary,
|
||||
PostCashBankOpeningRequest,
|
||||
PostOpeningStockRequest,
|
||||
} from "@/types/opening-balances"
|
||||
|
||||
export const openingBalancesApi = {
|
||||
/** The cash/bank accounts to enter amounts for — or the posted record, once it has been done. */
|
||||
cashBank(): Promise<CashBankOpeningState> {
|
||||
return apiRequest<CashBankOpeningState>("/opening-balances/cash-bank")
|
||||
},
|
||||
|
||||
postCashBank(request: PostCashBankOpeningRequest): Promise<OpeningBalanceRun> {
|
||||
return apiRequest<OpeningBalanceRun>("/opening-balances/cash-bank", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Opening-stock runs posted so far, newest first. */
|
||||
stockRuns(): Promise<OpeningStockRunSummary[]> {
|
||||
return apiRequest<OpeningStockRunSummary[]>("/opening-balances/stock")
|
||||
},
|
||||
|
||||
stockRun(runId: number): Promise<OpeningStockRun> {
|
||||
return apiRequest<OpeningStockRun>(`/opening-balances/stock/${runId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a FIFO layer per line and ONE journal (Dr Inventory / Cr Opening Balance Equity). 409 when an item
|
||||
* already has stock movements in that warehouse; untracked, stocked items only.
|
||||
*/
|
||||
postStock(request: PostOpeningStockRequest): Promise<OpeningStockRun> {
|
||||
return apiRequest<OpeningStockRun>("/opening-balances/stock", { method: "POST", body: request })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Procurement configuration. Singleton - no id. Controls the optional vendor contact / address
|
||||
// fields and whether purchase orders carry tax. The server enforces every flag too: a value sent
|
||||
// for a switched-off field returns 422 CONFIG_DISABLED, so the UI hides what is off.
|
||||
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
|
||||
import { ApiResult } from "@/types/common"
|
||||
import { ProcurementConfig, UpdateProcurementConfigRequest } from "@/types/procurement"
|
||||
|
||||
export const procurementConfigApi = {
|
||||
get(): Promise<ApiResult<ProcurementConfig>> {
|
||||
return apiRequestWithETag<ProcurementConfig>("/procurement-config")
|
||||
},
|
||||
|
||||
/** Full replacement — every flag is required. */
|
||||
update(request: UpdateProcurementConfigRequest, ifMatch: string): Promise<ApiResult<ProcurementConfig>> {
|
||||
return apiRequestWithETag<ProcurementConfig>("/procurement-config", { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
}
|
||||
|
||||
/** Read-only helper for screens that only need the flags. */
|
||||
export function procurementConfig(): Promise<ProcurementConfig> {
|
||||
return apiRequest<ProcurementConfig>("/procurement-config")
|
||||
}
|
||||
@@ -18,13 +18,18 @@ export interface ListPurchaseOrdersParams {
|
||||
q?: string
|
||||
status?: PurchaseOrderStatus
|
||||
vendorId?: number
|
||||
/** Created on or after this day (YYYY-MM-DD). */
|
||||
from?: string
|
||||
/** Created on or before this day (YYYY-MM-DD). */
|
||||
to?: string
|
||||
/** `created` (default, newest first) | `vendor` | `total` | `status`; a leading `-` flips the direction. */
|
||||
sort?: string
|
||||
}
|
||||
|
||||
/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO).
|
||||
/** Editable/deletable only while Draft or Rejected (FR-PROC-05, revised — submitting locks the PO; a rejected one is reopened for revision).
|
||||
* The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */
|
||||
export function isPoEditable(status: PurchaseOrderStatus): boolean {
|
||||
return status === "Draft"
|
||||
return status === "Draft" || status === "Rejected"
|
||||
}
|
||||
|
||||
export const purchaseOrdersApi = {
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface ListUsersParams {
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
roleCode?: string
|
||||
/** true = only administrators (users whose role is the administrator role, whatever it is called). */
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Which permission opens which page: the route guard in components/auth/AccessGate.tsx uses this so a role
|
||||
// can't open a screen by typing its address (the sidebar only hides the link). Derived from the sidebar's own
|
||||
// list, so a page and its menu entry can never disagree; the API enforces the same codes on every request, so this
|
||||
// is a courtesy that replaces a wall of "Forbidden" toasts with one clear message — never the protection.
|
||||
import { navItems } from "@/components/Layouts/AppSidebar"
|
||||
|
||||
interface RouteRule {
|
||||
prefix: string
|
||||
/** Any one of these opens the page. */
|
||||
codes: string[]
|
||||
}
|
||||
|
||||
/** Pages with no menu entry of their own, but that belong to a permission anyway. */
|
||||
const EXTRA_RULES: RouteRule[] = [
|
||||
{ prefix: "/dashboard/settings/audit-logs", codes: ["settings.audit-logs"] },
|
||||
]
|
||||
|
||||
function buildRules(): RouteRule[] {
|
||||
const rules: RouteRule[] = [...EXTRA_RULES]
|
||||
for (const item of navItems) {
|
||||
// The landing page and Help are open to everyone with a role (they hold no data of their own).
|
||||
if (item.code !== "dashboard" && item.code !== "help") rules.push({ prefix: item.href, codes: [item.code] })
|
||||
for (const child of item.children ?? []) {
|
||||
rules.push({ prefix: child.href, codes: [child.code, ...(child.altCodes ?? [])] })
|
||||
}
|
||||
}
|
||||
// Longest prefix first, so "/dashboard/procurement/purchase-orders/approvals" beats ".../purchase-orders".
|
||||
return rules.sort((a, b) => b.prefix.length - a.prefix.length)
|
||||
}
|
||||
|
||||
let cached: RouteRule[] | null = null
|
||||
|
||||
/**
|
||||
* The permission codes that open `pathname` (any one suffices), or null when the page is unguarded
|
||||
* (the landing page, Help, and anything not in the menu: those are protected by the API alone).
|
||||
*/
|
||||
export function requiredCodesFor(pathname: string): string[] | null {
|
||||
cached ??= buildRules()
|
||||
const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname
|
||||
const rule = cached.find((r) => path === r.prefix || path.startsWith(r.prefix + "/"))
|
||||
return rule ? rule.codes : null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Gathers everything a purchase-order PDF needs for ONE PO id and downloads it. Shared by the PO
|
||||
// table's row action and the PO detail page, so both produce the same document. The list endpoint
|
||||
// carries no lines, so the PO is always re-read in full here.
|
||||
import { companyProfile } from "@/lib/api/company-profile"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { downloadPurchaseOrderPdf } from "@/lib/pdf/purchase-order-pdf"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
import { ProcurementConfig } from "@/types/procurement"
|
||||
|
||||
export async function downloadPurchaseOrderPdfById(poId: number, config: ProcurementConfig): Promise<void> {
|
||||
const [po, itemPage, uomPage, warehousePage, company] = await Promise.all([
|
||||
purchaseOrdersApi.get(poId),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
companyProfile().catch(() => null),
|
||||
])
|
||||
|
||||
// The item list is capped, so any line item that is not in it is fetched on its own.
|
||||
const items: ItemListItem[] = [...itemPage.items]
|
||||
const missing = [...new Set(po.lines.map((l) => l.itemId))].filter((id) => !items.some((i) => i.itemId === id))
|
||||
const fetched = await Promise.all(missing.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null)))
|
||||
for (const item of fetched) if (item) items.push(item as unknown as ItemListItem)
|
||||
|
||||
await downloadPurchaseOrderPdf({
|
||||
po,
|
||||
items,
|
||||
uoms: uomPage.items,
|
||||
warehouses: warehousePage.items,
|
||||
config,
|
||||
company,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Purchase-order PDF — built in the browser with jsPDF (the backend has no PDF library), in the same
|
||||
// hand-drawn style as lib/pdf/day-end-report-pdf.ts (no jspdf-autotable dependency). One PDF per PO,
|
||||
// with the vendor block, the item lines and the totals. The vendor contact / address and the tax
|
||||
// columns appear only when Procurement configuration has them switched on.
|
||||
import type { jsPDF as JsPdf } from "jspdf"
|
||||
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { rateToPct } from "@/lib/po-tax"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { ProcurementConfig, PurchaseOrder } from "@/types/procurement"
|
||||
|
||||
const PAGE = { width: 210, height: 297, margin: 14 }
|
||||
const BOTTOM = PAGE.height - 16
|
||||
const CONTENT_W = PAGE.width - PAGE.margin * 2
|
||||
|
||||
type Column = { header: string; width: number; align?: "left" | "right" }
|
||||
|
||||
export interface PurchaseOrderPdfInput {
|
||||
po: PurchaseOrder
|
||||
items: readonly ItemListItem[]
|
||||
uoms: readonly Uom[]
|
||||
warehouses: readonly Warehouse[]
|
||||
config: ProcurementConfig
|
||||
company: CompanyProfile | null
|
||||
}
|
||||
|
||||
/** Cut `text` with "..." so it fits `maxWidth` mm at the current font. */
|
||||
function fit(doc: JsPdf, text: string, maxWidth: number): string {
|
||||
if (doc.getTextWidth(text) <= maxWidth) return text
|
||||
let cut = text
|
||||
while (cut.length > 1 && doc.getTextWidth(`${cut}...`) > maxWidth) cut = cut.slice(0, -1)
|
||||
return `${cut}...`
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
}
|
||||
|
||||
/** Company block (left) and document title block (right); returns the y where content starts. */
|
||||
function header(doc: JsPdf, po: PurchaseOrder, company: CompanyProfile | null): number {
|
||||
const name = company?.tradeName?.trim() || company?.legalName?.trim() || ""
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.setFontSize(15)
|
||||
doc.text(name || "Purchase Order", PAGE.margin, 18)
|
||||
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(90)
|
||||
let y = 23
|
||||
const companyLines = [
|
||||
company?.legalName && company.legalName !== name ? company.legalName : null,
|
||||
[company?.addressLine1, company?.addressLine2].filter(Boolean).join(", "),
|
||||
[company?.city, company?.country].filter(Boolean).join(", "),
|
||||
[company?.phone, company?.email].filter(Boolean).join(" | "),
|
||||
company?.taxRegistrationNo ? `Tax reg. ${company.taxRegistrationNo}` : null,
|
||||
].filter((l): l is string => !!l && l.trim().length > 0)
|
||||
for (const line of companyLines) {
|
||||
doc.text(fit(doc, line, 105), PAGE.margin, y)
|
||||
y += 4.2
|
||||
}
|
||||
|
||||
doc.setTextColor(0)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.setFontSize(18)
|
||||
doc.text("PURCHASE ORDER", PAGE.width - PAGE.margin, 18, { align: "right" })
|
||||
doc.setFontSize(10.5)
|
||||
doc.text(po.docNo, PAGE.width - PAGE.margin, 25, { align: "right" })
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(90)
|
||||
doc.text(`Status: ${statusLabel(po.status)}`, PAGE.width - PAGE.margin, 30, { align: "right" })
|
||||
doc.text(`Date: ${formatReportDate(po.createdAt)}`, PAGE.width - PAGE.margin, 34.5, { align: "right" })
|
||||
doc.setTextColor(0)
|
||||
|
||||
const bottom = Math.max(y, 38) + 2
|
||||
doc.setDrawColor(200)
|
||||
doc.line(PAGE.margin, bottom, PAGE.width - PAGE.margin, bottom)
|
||||
return bottom + 7
|
||||
}
|
||||
|
||||
/** "Vendor" box: name, code, and the contact / address the configuration enables. */
|
||||
function vendorBlock(doc: JsPdf, y: number, po: PurchaseOrder, config: ProcurementConfig): number {
|
||||
const v = po.vendor
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.setFontSize(10)
|
||||
doc.text("Vendor", PAGE.margin, y)
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setFontSize(10)
|
||||
y += 5.5
|
||||
|
||||
if (!v) {
|
||||
doc.text(`Vendor #${po.vendorId}`, PAGE.margin, y)
|
||||
return y + 8
|
||||
}
|
||||
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.text(fit(doc, v.name, CONTENT_W), PAGE.margin, y)
|
||||
doc.setFont("helvetica", "normal")
|
||||
y += 5
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(70)
|
||||
const rows: string[] = [`Code: ${v.code}`]
|
||||
if (config.vendorContactMode === "Mobile" && v.mobile) rows.push(`Mobile: ${v.mobile}`)
|
||||
if (config.vendorContactMode === "Email" && v.email) rows.push(`Email: ${v.email}`)
|
||||
for (const row of rows) {
|
||||
doc.text(fit(doc, row, CONTENT_W), PAGE.margin, y)
|
||||
y += 4.4
|
||||
}
|
||||
if (config.vendorAddressEnabled && v.address) {
|
||||
const wrapped = doc.splitTextToSize(`Address: ${v.address}`, CONTENT_W) as string[]
|
||||
for (const row of wrapped) {
|
||||
doc.text(row, PAGE.margin, y)
|
||||
y += 4.4
|
||||
}
|
||||
}
|
||||
doc.setTextColor(0)
|
||||
return y + 4
|
||||
}
|
||||
|
||||
/** A table that breaks across pages, repeating its header. */
|
||||
function table(doc: JsPdf, y: number, columns: Column[], rows: string[][]): number {
|
||||
const rowH = 6.5
|
||||
const drawRow = (top: number, cells: string[]) => {
|
||||
doc.setFontSize(8.5)
|
||||
let x = PAGE.margin
|
||||
columns.forEach((col, i) => {
|
||||
const text = fit(doc, cells[i] ?? "", col.width - 3)
|
||||
if (col.align === "right") doc.text(text, x + col.width - 1.5, top + 4.5, { align: "right" })
|
||||
else doc.text(text, x + 1.5, top + 4.5)
|
||||
x += col.width
|
||||
})
|
||||
}
|
||||
const drawHeader = (top: number) => {
|
||||
doc.setFillColor(238, 238, 236)
|
||||
doc.rect(PAGE.margin, top, CONTENT_W, rowH, "F")
|
||||
doc.setFont("helvetica", "bold")
|
||||
drawRow(top, columns.map((c) => c.header))
|
||||
doc.setFont("helvetica", "normal")
|
||||
return top + rowH
|
||||
}
|
||||
|
||||
let top = drawHeader(y)
|
||||
rows.forEach((cells, idx) => {
|
||||
if (top + rowH > BOTTOM) {
|
||||
doc.addPage()
|
||||
top = drawHeader(20)
|
||||
}
|
||||
if (idx % 2 === 1) {
|
||||
doc.setFillColor(248, 248, 246)
|
||||
doc.rect(PAGE.margin, top, CONTENT_W, rowH, "F")
|
||||
}
|
||||
drawRow(top, cells)
|
||||
top += rowH
|
||||
})
|
||||
doc.setDrawColor(200)
|
||||
doc.line(PAGE.margin, top, PAGE.width - PAGE.margin, top)
|
||||
return top + 5
|
||||
}
|
||||
|
||||
function totalsBlock(doc: JsPdf, y: number, po: PurchaseOrder, showTax: boolean): number {
|
||||
const rows: [string, string, boolean][] = [["Sub total", formatAmount(po.totals.subTotal), false]]
|
||||
if (showTax) rows.push(["Tax", formatAmount(po.totals.tax), false])
|
||||
rows.push([`Total (${po.totals.currency})`, formatAmount(po.totals.grandTotal), true])
|
||||
|
||||
const needed = rows.length * 6 + 4
|
||||
if (y + needed > BOTTOM) {
|
||||
doc.addPage()
|
||||
y = 20
|
||||
}
|
||||
const labelX = PAGE.width - PAGE.margin - 70
|
||||
const valueX = PAGE.width - PAGE.margin - 1.5
|
||||
for (const [label, value, strong] of rows) {
|
||||
if (strong) {
|
||||
doc.setDrawColor(150)
|
||||
doc.line(labelX, y - 3.8, PAGE.width - PAGE.margin, y - 3.8)
|
||||
}
|
||||
doc.setFont("helvetica", strong ? "bold" : "normal")
|
||||
doc.setFontSize(strong ? 10.5 : 9.5)
|
||||
doc.text(label, labelX, y)
|
||||
doc.text(value, valueX, y, { align: "right" })
|
||||
y += 6
|
||||
}
|
||||
doc.setFont("helvetica", "normal")
|
||||
return y
|
||||
}
|
||||
|
||||
function footer(doc: JsPdf, docNo: string) {
|
||||
const pages = doc.getNumberOfPages()
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
doc.setPage(i)
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(120)
|
||||
doc.text(`${docNo} — generated ${new Date().toLocaleString()}`, PAGE.margin, PAGE.height - 8)
|
||||
doc.text(`Page ${i} of ${pages}`, PAGE.width - PAGE.margin, PAGE.height - 8, { align: "right" })
|
||||
doc.setTextColor(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the PDF and triggers the browser download as `<doc no>.pdf`. */
|
||||
export async function downloadPurchaseOrderPdf(input: PurchaseOrderPdfInput): Promise<void> {
|
||||
const { po, items, uoms, warehouses, config, company } = input
|
||||
const showTax = config.poTaxEnabled
|
||||
|
||||
const { jsPDF } = await import("jspdf")
|
||||
const doc = new jsPDF({ unit: "mm", format: "a4" })
|
||||
|
||||
let y = header(doc, po, company)
|
||||
y = vendorBlock(doc, y, po, config)
|
||||
|
||||
const columns: Column[] = showTax
|
||||
? [
|
||||
{ header: "#", width: 7 },
|
||||
{ header: "SKU", width: 24 },
|
||||
{ header: "Item", width: 40 },
|
||||
{ header: "UOM", width: 14 },
|
||||
{ header: "Warehouse", width: 15 },
|
||||
{ header: "Qty", width: 13, align: "right" },
|
||||
{ header: "Unit price", width: 19, align: "right" },
|
||||
{ header: "Tax %", width: 11, align: "right" },
|
||||
{ header: "Tax amt", width: 16, align: "right" },
|
||||
{ header: "Line total", width: 23, align: "right" },
|
||||
]
|
||||
: [
|
||||
{ header: "#", width: 7 },
|
||||
{ header: "SKU", width: 26 },
|
||||
{ header: "Item", width: 56 },
|
||||
{ header: "UOM", width: 16 },
|
||||
{ header: "Warehouse", width: 17 },
|
||||
{ header: "Qty", width: 14, align: "right" },
|
||||
{ header: "Unit price", width: 20, align: "right" },
|
||||
{ header: "Line total", width: 26, align: "right" },
|
||||
]
|
||||
|
||||
const rows = po.lines.map((line, i) => {
|
||||
const item = items.find((it) => it.itemId === line.itemId)
|
||||
const warehouse = warehouses.find((w) => w.warehouseId === line.warehouseId)
|
||||
const net = line.qty * line.unitPrice
|
||||
const base = [
|
||||
String(i + 1),
|
||||
item?.sku ?? `#${line.itemId}`,
|
||||
item?.name ?? `Item #${line.itemId}`,
|
||||
baseUomLabel(items, uoms, line.itemId),
|
||||
warehouse?.code ?? `#${line.warehouseId}`,
|
||||
String(line.qty),
|
||||
formatAmount(line.unitPrice),
|
||||
]
|
||||
return showTax
|
||||
? [...base, `${rateToPct(line.tax)}%`, formatAmount(line.taxAmount ?? 0), formatAmount(net + (line.taxAmount ?? 0))]
|
||||
: [...base, formatAmount(net)]
|
||||
})
|
||||
|
||||
y = table(doc, y, columns, rows)
|
||||
totalsBlock(doc, y, po, showTax)
|
||||
footer(doc, po.docNo)
|
||||
|
||||
doc.save(`${po.docNo}.pdf`)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Purchase-order tax helpers. The UI works in percent (18 for 18%) because that is how an item's
|
||||
// tax rate is stored (`taxRatePercentage`) and how people say it; the PO API carries the rate as a
|
||||
// fraction 0..1 (0.18) and a stored amount. The server recomputes and stores the amount itself, so
|
||||
// these figures are for display and live totals only.
|
||||
|
||||
/** 18 -> 0.18. Four decimals, the precision the API stores. */
|
||||
export function pctToRate(pct: number): number {
|
||||
if (!Number.isFinite(pct) || pct <= 0) return 0
|
||||
return Math.round(pct * 100) / 10000
|
||||
}
|
||||
|
||||
/** 0.18 -> 18. */
|
||||
export function rateToPct(rate: number): number {
|
||||
if (!Number.isFinite(rate) || rate <= 0) return 0
|
||||
return Math.round(rate * 10000) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax amount for a line: qty x unit price x pct%, rounded half-up to 2 dp like the server
|
||||
* (`PurchaseOrderService.TaxAmountFor`). The epsilon keeps 1.005-style products from rounding down.
|
||||
*/
|
||||
export function lineTaxAmount(qty: number, unitPrice: number, pct: number): number {
|
||||
const q = Number(qty)
|
||||
const p = Number(unitPrice)
|
||||
const t = Number(pct)
|
||||
if (![q, p, t].every(Number.isFinite) || q <= 0 || p < 0 || t <= 0) return 0
|
||||
return Math.round(q * p * t + 1e-9) / 100
|
||||
}
|
||||
|
||||
/** The text of a percent for an input box: 18 -> "18", 8.25 -> "8.25", 0 -> "0". */
|
||||
export function pctText(pct: number | null | undefined): string {
|
||||
return pct === null || pct === undefined || !Number.isFinite(pct) ? "0" : String(pct)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export function validatePoLine(input: {
|
||||
if (input.unitPrice === "" || Number.isNaN(unitPrice) || unitPrice < 0) errors.unitPrice = "Unit price cannot be negative"
|
||||
const tax = Number(input.tax)
|
||||
if (input.tax === "" || Number.isNaN(tax) || tax < 0) errors.tax = "Tax rate cannot be negative"
|
||||
else if (tax > 1) errors.tax = "Tax cannot exceed 100%"
|
||||
return errors
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface ApprovalConfig {
|
||||
priceAdjustmentLevels: number
|
||||
/** With 2 levels: price changes smaller than this % need one approval. 0 = always two. */
|
||||
priceAdjustmentLevel2ThresholdPercent: number
|
||||
/** Only these roles may give the second approval; empty = anyone with the Approve action. */
|
||||
level2RoleCodes: string[]
|
||||
/** Only these roles (ids) may give the second approval; empty = anyone with the Approve action. */
|
||||
level2RoleIds: number[]
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PoVendor } from "@/types/procurement"
|
||||
|
||||
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
|
||||
// Mirrors the live backend DTOs. GET /grns and GET /grns/{id} both exist as of 2026-07-16.
|
||||
// Serial numbers (FR-GRN-04) are captured per unit for Serial-tracked items — see
|
||||
@@ -13,6 +15,7 @@ export type PurchaseOrderStatus =
|
||||
| "FullyReceived"
|
||||
| "Closed"
|
||||
| "Cancelled"
|
||||
| "Rejected"
|
||||
|
||||
export interface BatchInput {
|
||||
batchNo: string
|
||||
@@ -141,6 +144,10 @@ export interface GrnSummary {
|
||||
totalValue: number
|
||||
purchaseInvoiceId: number | null
|
||||
purchaseInvoiceNo: string | null
|
||||
/** The purchase order's document number, when this GRN is against one. */
|
||||
poDocNo?: string | null
|
||||
/** Vendor block; mobile / email / address are null unless the matching Procurement configuration switch is on. */
|
||||
vendor?: PoVendor | null
|
||||
}
|
||||
|
||||
/** docs/11 §4.2 confirm response. */
|
||||
|
||||
@@ -221,6 +221,10 @@ export interface Vendor {
|
||||
terms: string | null
|
||||
taxReg: string | null
|
||||
currency: string
|
||||
/** Optional contact details — recorded only while Procurement configuration enables them. */
|
||||
mobile?: string | null
|
||||
email?: string | null
|
||||
address?: string | null
|
||||
status: EntityStatus
|
||||
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
|
||||
branchId: number
|
||||
@@ -234,6 +238,10 @@ export interface CreateVendorRequest {
|
||||
terms?: string | null
|
||||
taxReg?: string | null
|
||||
currency: string
|
||||
/** Sent only while the matching Procurement configuration flag is on (else 422 CONFIG_DISABLED). */
|
||||
mobile?: string | null
|
||||
email?: string | null
|
||||
address?: string | null
|
||||
/** Required when branching is enabled — resolved client-side from the logged-in user's
|
||||
* own branch, or picked by an Admin viewing "All Branches" (see `useAuth()`). */
|
||||
branchId?: number
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Opening balances (cash and bank). One balanced journal in the General Ledger, posted once.
|
||||
|
||||
export interface OpeningCashBankAccount {
|
||||
accountType: "Cash" | "Bank" | string
|
||||
accountName: string
|
||||
bankName: string | null
|
||||
accountNumber: string | null
|
||||
glAccountCode: string
|
||||
currencyCode: string
|
||||
/** Filled only once the opening balance has been posted. */
|
||||
amount: number | null
|
||||
}
|
||||
|
||||
export interface OpeningBalanceLine {
|
||||
accountCode: string
|
||||
accountName: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
/** The record of the one-time posting. */
|
||||
export interface OpeningBalanceRun {
|
||||
openingBalanceRunId: number
|
||||
kind: string
|
||||
/** YYYY-MM-DD — the journal's entry date. */
|
||||
cutoverDate: string
|
||||
glJournalNo: string | null
|
||||
/** Net amount credited to Opening Balance Equity. */
|
||||
totalAmount: number
|
||||
currency: string
|
||||
postedAt: string
|
||||
postedBy: number
|
||||
lines: OpeningBalanceLine[]
|
||||
}
|
||||
|
||||
export interface CashBankOpeningState {
|
||||
posted: OpeningBalanceRun | null
|
||||
accounts: OpeningCashBankAccount[]
|
||||
baseCurrency: string
|
||||
}
|
||||
|
||||
export interface PostCashBankOpeningRequest {
|
||||
cutoverDate: string
|
||||
lines: { glAccountCode: string; amount: number }[]
|
||||
}
|
||||
|
||||
// ── Opening stock ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OpeningStockLine {
|
||||
itemId: number
|
||||
sku: string
|
||||
itemName: string
|
||||
warehouseId: number
|
||||
warehouseCode: string
|
||||
/** Base-UOM quantity. */
|
||||
qty: number
|
||||
unitCost: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface OpeningStockRunSummary {
|
||||
openingBalanceRunId: number
|
||||
/** YYYY-MM-DD */
|
||||
cutoverDate: string
|
||||
glJournalNo: string | null
|
||||
totalValue: number
|
||||
lineCount: number
|
||||
postedAt: string
|
||||
postedBy: number
|
||||
}
|
||||
|
||||
export interface OpeningStockRun extends OpeningStockRunSummary {
|
||||
lines: OpeningStockLine[]
|
||||
}
|
||||
|
||||
export interface PostOpeningStockRequest {
|
||||
cutoverDate: string
|
||||
lines: { itemId: number; warehouseId: number; qty: number; unitCost: number }[]
|
||||
}
|
||||
@@ -141,6 +141,7 @@ export type PurchaseOrderStatus =
|
||||
| "FullyReceived"
|
||||
| "Closed"
|
||||
| "Cancelled"
|
||||
| "Rejected"
|
||||
|
||||
export interface PoLine {
|
||||
poLineId: number
|
||||
@@ -151,6 +152,18 @@ export interface PoLine {
|
||||
/** Rate, 0..1 (e.g. 0.18), not an amount. */
|
||||
tax: number
|
||||
qtyReceived: number
|
||||
/** Stored tax amount for the line (0 while PO tax is switched off). */
|
||||
taxAmount: number
|
||||
}
|
||||
|
||||
/** Vendor block on a PO. mobile/email/address are null unless Procurement configuration enables them. */
|
||||
export interface PoVendor {
|
||||
vendorId: number
|
||||
code: string
|
||||
name: string
|
||||
mobile: string | null
|
||||
email: string | null
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export interface PoTotals {
|
||||
@@ -177,6 +190,7 @@ export interface PurchaseOrder {
|
||||
updatedAt: string | null
|
||||
totals: PoTotals
|
||||
lines: PoLine[]
|
||||
vendor?: PoVendor | null
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSummary {
|
||||
@@ -189,6 +203,7 @@ export interface PurchaseOrderSummary {
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
totals: PoTotals
|
||||
vendor?: PoVendor | null
|
||||
}
|
||||
|
||||
export interface CreatePoLineInput {
|
||||
@@ -276,3 +291,28 @@ export interface CreatePurchaseReturnRequest {
|
||||
reasonCodeId: number
|
||||
lines: CreatePurchaseReturnLineInput[]
|
||||
}
|
||||
|
||||
// ── Procurement configuration (Settings → Configuration → Procurement) ─────────────────────
|
||||
/** Which single contact detail vendors carry. */
|
||||
export type VendorContactMode = "None" | "Mobile" | "Email"
|
||||
|
||||
export interface ProcurementConfig {
|
||||
vendorContactMode: VendorContactMode
|
||||
vendorAddressEnabled: boolean
|
||||
/** Optional payment terms / tax registration on vendors (both on by default). */
|
||||
vendorPaymentTermsEnabled: boolean
|
||||
vendorTaxRegEnabled: boolean
|
||||
/** When on, PO lines carry a tax rate (prefilled from the item) and a tax amount. */
|
||||
poTaxEnabled: boolean
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
|
||||
/** Full replacement — every flag is required. */
|
||||
export interface UpdateProcurementConfigRequest {
|
||||
vendorContactMode: VendorContactMode
|
||||
vendorAddressEnabled: boolean
|
||||
vendorPaymentTermsEnabled: boolean
|
||||
vendorTaxRegEnabled: boolean
|
||||
poTaxEnabled: boolean
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Role {
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
/** The administrator role: its holders pass every permission check. Set by the system, never through the API. */
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
@@ -77,4 +79,10 @@ export interface MeResponse {
|
||||
branchName: string | null
|
||||
branchingEnabled: boolean
|
||||
warehouseBranchingEnabled: boolean
|
||||
/** The ERP role the person holds right now (read from the ERP, so a change applies on the next request). */
|
||||
roleId?: number | null
|
||||
/** False when the person has no usable role (none assigned and no default, or the role is inactive). */
|
||||
hasRole?: boolean
|
||||
/** Nav code -> granted actions bitmask (see RolePermissionAction). Administrators hold every code with all actions. */
|
||||
permissions?: Record<string, number>
|
||||
}
|
||||
|
||||
@@ -97,6 +97,14 @@ export interface SalesConfig {
|
||||
defaultWarehouseName: string | null
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
/** Optional customer fields. Each is on by default; when off the customer forms hide it and the server refuses a new value. */
|
||||
customerPhoneEnabled: boolean
|
||||
customerEmailEnabled: boolean
|
||||
customerAddressEnabled: boolean
|
||||
customerCityEnabled: boolean
|
||||
customerCountryEnabled: boolean
|
||||
customerCreditLimitEnabled: boolean
|
||||
customerCreditDaysEnabled: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSalesConfigRequest {
|
||||
@@ -107,6 +115,13 @@ export interface UpdateSalesConfigRequest {
|
||||
allowWarehouseSelection: boolean
|
||||
/** Required when `allowWarehouseSelection` is false. */
|
||||
defaultWarehouseId: number | null
|
||||
customerPhoneEnabled: boolean
|
||||
customerEmailEnabled: boolean
|
||||
customerAddressEnabled: boolean
|
||||
customerCityEnabled: boolean
|
||||
customerCountryEnabled: boolean
|
||||
customerCreditLimitEnabled: boolean
|
||||
customerCreditDaysEnabled: boolean
|
||||
}
|
||||
|
||||
/** Issuable stock for one item at one warehouse. `isStocked` false = non-stock item, never short. */
|
||||
|
||||
Reference in New Issue
Block a user