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>
|
||||
|
||||
Reference in New Issue
Block a user