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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+245
-245
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user