feat: enhance purchase orders management with delete and approve actions

- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
This commit is contained in:
2026-08-05 10:56:05 +05:30
parent cb3f1e5cde
commit 5d0ea3f035
7 changed files with 592 additions and 374 deletions
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react" import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
@@ -195,10 +195,10 @@ export default function PurchaseOrderDetailPage() {
const updated = await purchaseOrdersApi.submit(po.poId) const updated = await purchaseOrdersApi.submit(po.poId)
setPo(updated) setPo(updated)
setLines(toDraftLines(updated)) setLines(toDraftLines(updated))
toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`) toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
} catch (err) { } catch (err) {
setSaveError(errorMessage(err)) setSaveError(errorMessage(err))
toast.error("Could not submit purchase order", errorMessage(err)) toast.error("Could not approve purchase order", errorMessage(err))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -286,9 +286,9 @@ export default function PurchaseOrderDetailPage() {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{po.status === "Draft" && ( {po.status === "Draft" && (
<> <>
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}> <Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" /> <Check className="size-5" />
{submitting ? "Submitting…" : "Submit"} {submitting ? "Approving…" : "Approve"}
</Button> </Button>
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}> <Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Trash2 className="size-5" /> <Trash2 className="size-5" />
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react" import { ArrowLeft, ExternalLink, Plus, Trash2 } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders" import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { requisitionsApi } from "@/lib/api/requisitions" import { requisitionsApi } from "@/lib/api/requisitions"
@@ -15,13 +15,22 @@ import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement" import { validatePoLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { CreatePoLineInput } from "@/types/procurement" import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -73,22 +82,83 @@ function NewPurchaseOrderContent() {
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [vendorDialogOpen, setVendorDialogOpen] = useState(false)
const [vName, setVName] = useState("")
const [vTerms, setVTerms] = useState("")
const [vTaxReg, setVTaxReg] = useState("")
const [vCurrency, setVCurrency] = useState("LKR")
const [vErrors, setVErrors] = useState<Record<string, string>>({})
const [vSubmitting, setVSubmitting] = useState(false)
const generatedVendorCode = vName.trim() ? generateVendorCode(vName, (vendors ?? []).map((v) => v.code)) : ""
function loadItems() {
return itemsApi.list({ pageSize: 200, status: "Active" }).then((it) => setItems(it.items))
}
function loadVendors() {
return vendorsApi.list({ pageSize: 200, status: "Active" }).then((ve) => setVendors(ve.items))
}
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }), loadItems(),
uomsApi.list(), uomsApi.list().then((uo) => setUoms(uo.items)),
warehousesApi.list(), warehousesApi.list().then((wh) => setWarehouses(wh.items)),
vendorsApi.list({ pageSize: 200, status: "Active" }), loadVendors(),
]) ]).catch((err) => setLoadError(errorMessage(err)))
.then(([it, uo, wh, ve]) => { // eslint-disable-next-line react-hooks/exhaustive-deps
setItems(it.items)
setUoms(uo.items)
setWarehouses(wh.items)
setVendors(ve.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, []) }, [])
// A new item is created on the standalone item builder (too many fields for a modal here),
// typically in another tab — refetch on refocus so it shows up in the line pickers without
// the user having to reload this page and lose their draft.
useEffect(() => {
function onFocus() {
loadItems().catch(() => {})
}
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function resetVendorForm() {
setVName("")
setVTerms("")
setVTaxReg("")
setVCurrency("LKR")
setVErrors({})
}
async function handleCreateVendor() {
const nextErrors: Record<string, string> = {}
if (!vName.trim()) nextErrors.name = "Vendor name is required"
if (!vCurrency.trim()) nextErrors.currency = "Currency is required"
setVErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setVSubmitting(true)
try {
const result = await vendorsApi.create({
code: generatedVendorCode,
name: vName,
terms: vTerms || null,
taxReg: vTaxReg || null,
currency: vCurrency,
})
await loadVendors()
setVendorId(result.data.vendorId)
toast.success("Vendor created", `${result.data.code}${result.data.name}`)
setVendorDialogOpen(false)
resetVendorForm()
} catch (err) {
// A 409 here means another creation raced ours for the same generated code — the
// proactive de-dupe above only knows about vendors loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err))
} finally {
setVSubmitting(false)
}
}
useEffect(() => { useEffect(() => {
if (requisitionId) { if (requisitionId) {
requisitionsApi requisitionsApi
@@ -239,43 +309,72 @@ function NewPurchaseOrderContent() {
{!loading && ( {!loading && (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Vendor code</Label> <Label className="text-base">Vendor</Label>
<Select<number | null> <div className="flex items-center gap-2">
value={vendorId} <Select<number | null>
onValueChange={setVendorId} value={vendorId}
items={(vendors ?? []).map((v) => ({ label: v.code, value: v.vendorId }))} onValueChange={setVendorId}
> items={(vendors ?? []).map((v) => ({ label: `${v.code}${v.name}`, value: v.vendorId }))}
<SelectTrigger className="h-12! w-full text-base"> >
<SelectValue placeholder="Select vendor code" /> <SelectTrigger className="h-12! w-full text-base">
</SelectTrigger> <SelectValue placeholder="Select vendor" />
<SelectContent> </SelectTrigger>
{(vendors ?? []).map((v) => ( <SelectContent>
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base"> {(vendors ?? []).map((v) => (
{v.code} <SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
</SelectItem> {v.code} {v.name}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</div> </Select>
<div className="flex flex-col gap-2">
<Label className="text-base">Vendor name</Label> <Dialog open={vendorDialogOpen} onOpenChange={(o) => { setVendorDialogOpen(o); if (!o) resetVendorForm() }}>
<Select<number | null> <DialogTrigger
value={vendorId} render={<Button type="button" variant="outline" size="icon-lg" aria-label="New vendor" title="New vendor" />}
onValueChange={setVendorId} >
items={(vendors ?? []).map((v) => ({ label: v.name, value: v.vendorId }))} <Plus className="size-5" />
> </DialogTrigger>
<SelectTrigger className="h-12! w-full text-base"> <DialogContent className="sm:max-w-md">
<SelectValue placeholder="Select vendor name" /> <DialogHeader className="items-center text-center">
</SelectTrigger> <DialogTitle>New vendor</DialogTitle>
<SelectContent> <DialogDescription>Create a supplier record without leaving this PO. Its code is generated from the name.</DialogDescription>
{(vendors ?? []).map((v) => ( </DialogHeader>
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base"> <FieldGroup>
{v.name} <Field data-invalid={!!vErrors.name}>
</SelectItem> <FieldLabel htmlFor="po-v-name">Name</FieldLabel>
))} <Input id="po-v-name" value={vName} onChange={(e) => setVName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!vErrors.name} />
</SelectContent> <FieldError errors={[vErrors.name ? { message: vErrors.name } : undefined]} />
</Select> </Field>
<Field>
<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>
<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} />
<FieldError errors={[vErrors.currency ? { message: vErrors.currency } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setVendorDialogOpen(false)} disabled={vSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreateVendor} disabled={vSubmitting}>
{vSubmitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div> </div>
{requisitionId && ( {requisitionId && (
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div> <div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
@@ -290,10 +389,22 @@ function NewPurchaseOrderContent() {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2> <h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}> <div className="flex items-center gap-2">
<Plus className="size-5" /> <Link
Add line href="/dashboard/products/new"
</Button> target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline" }))}
title="Opens in a new tab — the item list here refreshes when you come back"
>
<ExternalLink className="size-5" />
New item
</Link>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
</div>
</div> </div>
{lines.length > 0 && ( {lines.length > 0 && (
@@ -2,9 +2,9 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react" import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement" import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" 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 { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All" type StatusFilter = PurchaseOrderStatus | "All"
@@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [approvingId, setApprovingId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
} }
async function handleDelete(po: PurchaseOrderSummary) {
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
setDeletingId(po.poId)
try {
await purchaseOrdersApi.remove(po.poId)
toast.success("Draft deleted", po.docNo)
load()
} catch (err) {
toast.error("Could not delete purchase order", errorMessage(err))
} finally {
setDeletingId(null)
}
}
async function handleApprove(po: PurchaseOrderSummary) {
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
setApprovingId(po.poId)
try {
const updated = await purchaseOrdersApi.submit(po.poId)
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status}.`)
load()
} catch (err) {
toast.error("Could not approve purchase order", errorMessage(err))
} finally {
setApprovingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All" const hasFilters = query.length > 0 || status !== "All"
return ( return (
@@ -133,24 +164,76 @@ export default function PurchaseOrdersListPage() {
<TableHead className="h-12 px-3 text-sm">Status</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">Grand total</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead> <TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pos.map((po) => ( {pos.map((po) => {
<TableRow key={po.poId}> const editable = isPoEditable(po.status)
<TableCell className="px-3 py-3.5"> return (
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline"> <TableRow key={po.poId}>
{po.docNo} <TableCell className="px-3 py-3.5">
</Link> <Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
</TableCell> {po.docNo}
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell> </Link>
<TableCell className="px-3 py-3.5"> </TableCell>
<PoStatusBadge status={po.status} /> <TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
</TableCell> <TableCell className="px-3 py-3.5">
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell> <PoStatusBadge status={po.status} />
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell> </TableCell>
</TableRow> <TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
))} <TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="View"
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>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Approve draft"
title="Approve draft"
disabled={approvingId === po.poId || deletingId === po.poId}
onClick={() => handleApprove(po)}
className="text-success hover:bg-success/10 hover:text-success"
>
<Check className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || approvingId === po.poId}
onClick={() => handleDelete(po)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody> </TableBody>
</Table> </Table>
@@ -1,6 +1,6 @@
"use client" "use client"
import { Plus, Trash2, X } from "lucide-react" import { Plus, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CustomFieldType, StageInputSource } from "@/types/production" import { CustomFieldType, StageInputSource } from "@/types/production"
@@ -15,6 +15,7 @@ import {
} from "./types" } from "./types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field" import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -175,265 +176,264 @@ export function StageEditorPanel({
} }
return ( return (
<div className="flex h-full w-full flex-col overflow-y-auto rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:w-96"> <Dialog open onOpenChange={(next) => !next && onClose()}>
<div className="mb-4 flex items-center justify-between"> <DialogContent className="max-h-[85vh] w-full max-w-lg overflow-y-auto sm:max-w-lg">
<h2 className="text-base font-bold text-foreground">Stage editor</h2> <DialogHeader>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close panel"> <DialogTitle>Stage editor</DialogTitle>
<X className="size-5" /> </DialogHeader>
</button>
</div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Field> <Field>
<FieldLabel>Name</FieldLabel> <FieldLabel>Name</FieldLabel>
<Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" /> <Input value={data.name} disabled={readOnly} onChange={(e) => onChange({ name: e.target.value })} placeholder="e.g. Welding" />
</Field> </Field>
<Field> <Field>
<FieldLabel>Role label</FieldLabel> <FieldLabel>Role label</FieldLabel>
<Input <Input
value={data.roleLabel} value={data.roleLabel}
disabled={readOnly} disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })} onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA" placeholder="e.g. QA"
list="role-suggestions" list="role-suggestions"
/> />
<datalist id="role-suggestions"> <datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => ( {ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} /> <option key={r} value={r} />
))} ))}
</datalist> </datalist>
</Field> </Field>
<Field> <Field>
<FieldLabel>Estimated minutes</FieldLabel> <FieldLabel>Estimated minutes</FieldLabel>
<Input <Input
type="number" type="number"
min={0} min={0}
value={data.estimatedMinutes} value={data.estimatedMinutes}
disabled={readOnly} disabled={readOnly}
onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })} onChange={(e) => onChange({ estimatedMinutes: Number(e.target.value) || 0 })}
/> />
</Field> </Field>
{/* Inputs */} {/* Inputs */}
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Inputs</p> <p className="text-sm font-semibold text-foreground">Inputs</p>
{!readOnly && ( {!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addInput}> <Button type="button" variant="ghost" size="sm" onClick={addInput}>
<Plus className="size-4" /> <Plus className="size-4" />
Add Add
</Button> </Button>
)} )}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>} {data.inputs.length === 0 && <p className="text-sm text-muted-foreground">No inputs yet.</p>}
{data.inputs.map((input) => ( {data.inputs.map((input) => (
<div key={input.localId} className="rounded-lg border border-border p-2.5"> <div key={input.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<Select<StageInputSource> <Select<StageInputSource>
value={input.source} value={input.source}
onValueChange={(v) => v && changeInputSource(input.localId, v)} onValueChange={(v) => v && changeInputSource(input.localId, v)}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stock" className="text-sm">Stock</SelectItem>
<SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
</SelectContent>
</Select>
{!readOnly && (
<button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
<Trash2 className="size-4" />
</button>
)}
</div>
<div className="flex flex-col gap-2">
{input.source === "Stock" ? (
<Select<number>
value={input.itemId}
onValueChange={(v) => v && pickInputItem(input, v)}
> >
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick an item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select<string>
value={input.fromOutputKey}
onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
<SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
</SelectTrigger>
<SelectContent>
{upstreamOptions.map((o) => (
<SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
{o.stageName} {o.outputName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<QtyRow
qty={input.qtyPerBatch}
uomId={input.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
onUomChange={(uomId) => updateInput(input.localId, { uomId })}
/>
</div>
</div>
))}
</div>
</div>
{/* Outputs */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">
Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal finished good)</span>}
</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addOutput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
{data.outputs.map((output) => (
<div key={output.key} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
{isTerminal ? (
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}> <SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick the finished-good item" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{items.map((i) => ( <SelectItem value="Stock" className="text-sm">Stock</SelectItem>
<SelectItem key={i.itemId} value={i.itemId} className="text-sm"> <SelectItem value="Upstream" className="text-sm">Upstream</SelectItem>
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( {!readOnly && (
<Input <button type="button" onClick={() => removeInput(input.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove input">
value={output.name} <Trash2 className="size-4" />
disabled={readOnly} </button>
onChange={(e) => updateOutput(output.key, { name: e.target.value })} )}
placeholder="Output name (work in progress)" </div>
className="h-8 flex-1 text-sm"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" />
</button>
)}
</div>
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
/>
</div>
))}
</div>
</div>
{/* Custom fields */} <div className="flex flex-col gap-2">
<div> {input.source === "Stock" ? (
<div className="mb-2 flex items-center justify-between"> <Select<number>
<p className="text-sm font-semibold text-foreground">Custom fields</p> value={input.itemId}
{!readOnly && ( onValueChange={(v) => v && pickInputItem(input, v)}
<Button type="button" variant="ghost" size="sm" onClick={addField}> >
<Plus className="size-4" /> <SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
Add <SelectValue placeholder="Pick an item" />
</Button> </SelectTrigger>
)} <SelectContent>
</div> {items.map((i) => (
<div className="flex flex-col gap-2"> <SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>} {i.name} · {i.sku}
{data.fieldDefs.map((field) => ( </SelectItem>
<div key={field.localId} className="rounded-lg border border-border p-2.5"> ))}
<div className="mb-2 flex items-center gap-2"> </SelectContent>
<Input </Select>
value={field.label} ) : (
disabled={readOnly} <Select<string>
onChange={(e) => updateField(field.localId, { label: e.target.value })} value={input.fromOutputKey}
placeholder="Label" onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })}
className="h-8 flex-1 text-sm" >
/> <SelectTrigger className="h-8! w-full text-sm" disabled={readOnly || upstreamOptions.length === 0}>
{!readOnly && ( <SelectValue placeholder={upstreamOptions.length === 0 ? "No upstream stages connected" : "Pick an upstream output"} />
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field"> </SelectTrigger>
<Trash2 className="size-4" /> <SelectContent>
</button> {upstreamOptions.map((o) => (
)} <SelectItem key={o.outputKey} value={o.outputKey} className="text-sm">
</div> {o.stageName} {o.outputName}
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>} </SelectItem>
<div className="flex items-center gap-2"> ))}
<Select<CustomFieldType> </SelectContent>
value={field.type} </Select>
onValueChange={(v) => v && updateField(field.localId, { type: v })} )}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}> <QtyRow
<SelectValue /> qty={input.qtyPerBatch}
</SelectTrigger> uomId={input.uomId}
<SelectContent> uoms={uoms}
{FIELD_TYPES.map((t) => ( readOnly={readOnly}
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem> onQtyChange={(qtyPerBatch) => updateInput(input.localId, { qtyPerBatch })}
))} onUomChange={(uomId) => updateInput(input.localId, { uomId })}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Switch
size="sm"
checked={field.required}
disabled={readOnly}
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
/> />
<span className="text-xs text-muted-foreground">Required</span>
</div> </div>
</div> </div>
{field.type === "Select" && ( ))}
<Input </div>
value={field.options.join(", ")}
disabled={readOnly}
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Options, comma separated"
className="mt-2 h-8 text-sm"
/>
)}
</div>
))}
</div> </div>
</div>
{!readOnly && ( {/* Outputs */}
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}> <div>
<Trash2 className="size-4" /> <div className="mb-2 flex items-center justify-between">
Delete stage <p className="text-sm font-semibold text-foreground">
</Button> Outputs{isTerminal && <span className="ml-1.5 font-normal text-muted-foreground">(terminal finished good)</span>}
)} </p>
</div> {!readOnly && (
</div> <Button type="button" variant="ghost" size="sm" onClick={addOutput}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.outputs.length === 0 && <p className="text-sm text-muted-foreground">No outputs yet.</p>}
{data.outputs.map((output) => (
<div key={output.key} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
{isTerminal ? (
<Select<number> value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick the finished-good item" />
</SelectTrigger>
<SelectContent>
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={output.name}
disabled={readOnly}
onChange={(e) => updateOutput(output.key, { name: e.target.value })}
placeholder="Output name (work in progress)"
className="h-8 flex-1 text-sm"
/>
)}
{!readOnly && (
<button type="button" onClick={() => removeOutput(output.key)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove output">
<Trash2 className="size-4" />
</button>
)}
</div>
<QtyRow
qty={output.qtyPerBatch}
uomId={output.uomId}
uoms={uoms}
readOnly={readOnly}
onQtyChange={(qtyPerBatch) => updateOutput(output.key, { qtyPerBatch })}
onUomChange={(uomId) => updateOutput(output.key, { uomId })}
/>
</div>
))}
</div>
</div>
{/* Custom fields */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Custom fields</p>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" onClick={addField}>
<Plus className="size-4" />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{data.fieldDefs.length === 0 && <p className="text-sm text-muted-foreground">No custom fields.</p>}
{data.fieldDefs.map((field) => (
<div key={field.localId} className="rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center gap-2">
<Input
value={field.label}
disabled={readOnly}
onChange={(e) => updateField(field.localId, { label: e.target.value })}
placeholder="Label"
className="h-8 flex-1 text-sm"
/>
{!readOnly && (
<button type="button" onClick={() => removeField(field.localId)} className="shrink-0 text-muted-foreground hover:text-destructive" aria-label="Remove field">
<Trash2 className="size-4" />
</button>
)}
</div>
{field.key && <p className="mb-2 font-mono text-xs text-muted-foreground">key: {field.key}</p>}
<div className="flex items-center gap-2">
<Select<CustomFieldType>
value={field.type}
onValueChange={(v) => v && updateField(field.localId, { type: v })}
>
<SelectTrigger className="h-8! flex-1 text-sm" disabled={readOnly}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-sm">{t}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Switch
size="sm"
checked={field.required}
disabled={readOnly}
onCheckedChange={(checked) => updateField(field.localId, { required: checked })}
/>
<span className="text-xs text-muted-foreground">Required</span>
</div>
</div>
{field.type === "Select" && (
<Input
value={field.options.join(", ")}
disabled={readOnly}
onChange={(e) => updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
placeholder="Options, comma separated"
className="mt-2 h-8 text-sm"
/>
)}
</div>
))}
</div>
</div>
{!readOnly && (
<Button type="button" variant="outline" className={cn("mt-2 text-destructive hover:bg-destructive/10")} onClick={onDelete}>
<Trash2 className="size-4" />
Delete stage
</Button>
)}
</div>
</DialogContent>
</Dialog>
) )
} }
@@ -768,46 +768,44 @@ export default function TemplateBuilderPage() {
</div> </div>
)} )}
<div className="flex min-h-0 flex-1 gap-4"> <div className="min-h-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10">
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/10"> {mounted && (
{mounted && ( <ReactFlow
<ReactFlow nodes={displayNodes}
nodes={displayNodes} edges={edges}
edges={edges} nodeTypes={nodeTypes}
nodeTypes={nodeTypes} onNodesChange={locked ? undefined : onNodesChange}
onNodesChange={locked ? undefined : onNodesChange} onEdgesChange={locked ? undefined : onEdgesChange}
onEdgesChange={locked ? undefined : onEdgesChange} onNodesDelete={locked ? undefined : onNodesDelete}
onNodesDelete={locked ? undefined : onNodesDelete} onConnect={locked ? undefined : onConnect}
onConnect={locked ? undefined : onConnect} onNodeClick={onNodeClick}
onNodeClick={onNodeClick} onPaneClick={onPaneClick}
onPaneClick={onPaneClick} nodesDraggable={!locked}
nodesDraggable={!locked} nodesConnectable={!locked}
nodesConnectable={!locked} elementsSelectable
elementsSelectable colorMode={resolvedTheme === "dark" ? "dark" : "light"}
colorMode={resolvedTheme === "dark" ? "dark" : "light"} fitView
fitView >
> <Background />
<Background /> <Controls showInteractive={!locked} />
<Controls showInteractive={!locked} /> <MiniMap pannable zoomable />
<MiniMap pannable zoomable /> </ReactFlow>
</ReactFlow>
)}
</div>
{selectedNode && (
<StageEditorPanel
data={selectedNode.data as StageNodeData}
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
onDelete={() => deleteNode(selectedNode.id)}
onClose={() => setSelectedNodeId(null)}
/>
)} )}
</div> </div>
{selectedNode && (
<StageEditorPanel
data={selectedNode.data as StageNodeData}
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
onDelete={() => deleteNode(selectedNode.id)}
onClose={() => setSelectedNodeId(null)}
/>
)}
</div> </div>
) )
} }
+21 -13
View File
@@ -5,8 +5,9 @@ import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react" import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { EntityStatus, PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Vendor } from "@/types/master-data" import { Vendor } from "@/types/master-data"
@@ -44,7 +45,6 @@ export default function VendorsPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("") const [name, setName] = useState("")
const [terms, setTerms] = useState("") const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("") const [taxReg, setTaxReg] = useState("")
@@ -52,8 +52,14 @@ export default function VendorsPage() {
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
// Separate from the paginated table list above — this needs every existing code (up to the
// server's page-size cap) to de-dupe against, not just the current page's 5 rows.
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
@@ -75,8 +81,11 @@ export default function VendorsPage() {
useEffect(load, [query, status, page]) useEffect(load, [query, status, page])
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
}, [])
function resetForm() { function resetForm() {
setCode("")
setName("") setName("")
setTerms("") setTerms("")
setTaxReg("") setTaxReg("")
@@ -86,7 +95,6 @@ export default function VendorsPage() {
async function handleCreate() { async function handleCreate() {
const nextErrors: Record<string, string> = {} const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Vendor code is required"
if (!name.trim()) nextErrors.name = "Vendor name is required" if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required" if (!currency.trim()) nextErrors.currency = "Currency is required"
setErrors(nextErrors) setErrors(nextErrors)
@@ -94,14 +102,15 @@ export default function VendorsPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const result = await vendorsApi.create({ code, name, terms: terms || null, taxReg: taxReg || null, currency }) const result = await vendorsApi.create({ code: generatedCode, name, terms: terms || null, taxReg: taxReg || null, currency })
toast.success("Vendor created", `${result.data.code}${result.data.name}`) toast.success("Vendor created", `${result.data.code}${result.data.name}`)
setOpen(false) setOpen(false)
resetForm() resetForm()
load() load()
setAllVendorCodes((codes) => [...codes, result.data.code])
} catch (err) { } catch (err) {
const fe = fieldErrors(err) // A 409 here means another creation raced ours for the same generated code — the
if (fe?.code) setErrors({ code: fe.code }) // proactive de-dupe above only knows about codes loaded when the dialog opened.
toast.error("Could not create vendor", errorMessage(err)) toast.error("Could not create vendor", errorMessage(err))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
@@ -145,19 +154,18 @@ export default function VendorsPage() {
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle> <DialogTitle>New vendor</DialogTitle>
<DialogDescription>Create a supplier record.</DialogDescription> <DialogDescription>Create a supplier record. Its code is generated from the name.</DialogDescription>
</DialogHeader> </DialogHeader>
<FieldGroup> <FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="v-code">Code</FieldLabel>
<Input id="v-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="v-name">Name</FieldLabel> <FieldLabel htmlFor="v-name">Name</FieldLabel>
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} /> <Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> <FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field> </Field>
<Field>
<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> <Field>
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel> <FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" /> <Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
+18
View File
@@ -0,0 +1,18 @@
/** First word of the name, uppercased and stripped to alphanumerics — falls back to "VN"
* so an empty/punctuation-only name still yields a usable base. Mirrors the warehouse
* code generator (app/dashboard/warehouse/page.tsx). */
function vendorCodeBase(name: string): string {
const firstWord = name.trim().split(/\s+/)[0] ?? ""
const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "")
return cleaned.slice(0, 10) || "VN"
}
/** Appends a numeric suffix until the code doesn't collide with an existing one — the
* backend enforces global uniqueness (409 on conflict) but has no generation of its own. */
export function generateVendorCode(name: string, existingCodes: string[]): string {
const base = `VN-${vendorCodeBase(name)}`
if (!existingCodes.includes(base)) return base
let suffix = 2
while (existingCodes.includes(`${base}${suffix}`)) suffix += 1
return `${base}${suffix}`
}