diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index 65c6a6b..634532c 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" 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 { warehousesApi } from "@/lib/api/warehouses" @@ -195,10 +195,10 @@ export default function PurchaseOrderDetailPage() { const updated = await purchaseOrdersApi.submit(po.poId) setPo(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) { setSaveError(errorMessage(err)) - toast.error("Could not submit purchase order", errorMessage(err)) + toast.error("Could not approve purchase order", errorMessage(err)) } finally { setSubmitting(false) } @@ -286,9 +286,9 @@ export default function PurchaseOrderDetailPage() {
{po.status === "Draft" && ( <> - + +
+ + + {requisitionId && (
From Requisition #{requisitionId}
@@ -290,10 +389,22 @@ function NewPurchaseOrderContent() {

Lines

- +
+ + + New item + + +
{lines.length > 0 && ( diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx index bf4b352..7676f2e 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from "react" 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 { errorMessage } from "@/lib/error-map" import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement" @@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" import { PoStatusBadge } from "@/components/procurement/status-badges" type StatusFilter = PurchaseOrderStatus | "All" @@ -32,6 +33,8 @@ export default function PurchaseOrdersListPage() { const [query, setQuery] = useState("") const [status, setStatus] = useState("All") const [page, setPage] = useState(1) + const [deletingId, setDeletingId] = useState(null) + const [approvingId, setApprovingId] = useState(null) useEffect(() => { const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) @@ -60,6 +63,34 @@ export default function PurchaseOrdersListPage() { 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" return ( @@ -133,24 +164,76 @@ export default function PurchaseOrdersListPage() { Status Grand total Created + Actions - {pos.map((po) => ( - - - - {po.docNo} - - - {vendorCode(po.vendorId)} - - - - {po.totals.currency} {po.totals.grandTotal.toFixed(2)} - {new Date(po.createdAt).toLocaleString()} - - ))} + {pos.map((po) => { + const editable = isPoEditable(po.status) + return ( + + + + {po.docNo} + + + {vendorCode(po.vendorId)} + + + + {po.totals.currency} {po.totals.grandTotal.toFixed(2)} + {new Date(po.createdAt).toLocaleString()} + +
+ + + + {editable && ( + <> + + + + + + + )} +
+
+
+ ) + })}
diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx index 825740d..613f95b 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx @@ -1,6 +1,6 @@ "use client" -import { Plus, Trash2, X } from "lucide-react" +import { Plus, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" import { CustomFieldType, StageInputSource } from "@/types/production" @@ -15,6 +15,7 @@ import { } from "./types" import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Field, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -175,265 +176,264 @@ export function StageEditorPanel({ } return ( -
-
-

Stage editor

- -
+ !next && onClose()}> + + + Stage editor + -
- - Name - onChange({ name: e.target.value })} placeholder="e.g. Welding" /> - +
+ + Name + onChange({ name: e.target.value })} placeholder="e.g. Welding" /> + - - Role label - onChange({ roleLabel: e.target.value })} - placeholder="e.g. QA" - list="role-suggestions" - /> - - {ROLE_SUGGESTIONS.map((r) => ( - - + + Role label + onChange({ roleLabel: e.target.value })} + placeholder="e.g. QA" + list="role-suggestions" + /> + + {ROLE_SUGGESTIONS.map((r) => ( + + - - Estimated minutes - onChange({ estimatedMinutes: Number(e.target.value) || 0 })} - /> - + + Estimated minutes + onChange({ estimatedMinutes: Number(e.target.value) || 0 })} + /> + - {/* Inputs */} -
-
-

Inputs

- {!readOnly && ( - - )} -
-
- {data.inputs.length === 0 &&

No inputs yet.

} - {data.inputs.map((input) => ( -
-
- - value={input.source} - onValueChange={(v) => v && changeInputSource(input.localId, v)} - > - - - - - Stock - Upstream - - - {!readOnly && ( - - )} -
- -
- {input.source === "Stock" ? ( - - value={input.itemId} - onValueChange={(v) => v && pickInputItem(input, v)} + {/* Inputs */} +
+
+

Inputs

+ {!readOnly && ( + + )} +
+
+ {data.inputs.length === 0 &&

No inputs yet.

} + {data.inputs.map((input) => ( +
+
+ + value={input.source} + onValueChange={(v) => v && changeInputSource(input.localId, v)} > - - - - - {items.map((i) => ( - - {i.name} · {i.sku} - - ))} - - - ) : ( - - value={input.fromOutputKey} - onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })} - > - - - - - {upstreamOptions.map((o) => ( - - {o.stageName} — {o.outputName} - - ))} - - - )} - - updateInput(input.localId, { qtyPerBatch })} - onUomChange={(uomId) => updateInput(input.localId, { uomId })} - /> -
-
- ))} -
-
- - {/* Outputs */} -
-
-

- Outputs{isTerminal && (terminal — finished good)} -

- {!readOnly && ( - - )} -
-
- {data.outputs.length === 0 &&

No outputs yet.

} - {data.outputs.map((output) => ( -
-
- {isTerminal ? ( - value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}> - + - {items.map((i) => ( - - {i.name} · {i.sku} - - ))} + Stock + Upstream - ) : ( - updateOutput(output.key, { name: e.target.value })} - placeholder="Output name (work in progress)" - className="h-8 flex-1 text-sm" - /> - )} - {!readOnly && ( - - )} -
- updateOutput(output.key, { qtyPerBatch })} - onUomChange={(uomId) => updateOutput(output.key, { uomId })} - /> -
- ))} -
-
+ {!readOnly && ( + + )} +
- {/* Custom fields */} -
-
-

Custom fields

- {!readOnly && ( - - )} -
-
- {data.fieldDefs.length === 0 &&

No custom fields.

} - {data.fieldDefs.map((field) => ( -
-
- updateField(field.localId, { label: e.target.value })} - placeholder="Label" - className="h-8 flex-1 text-sm" - /> - {!readOnly && ( - - )} -
- {field.key &&

key: {field.key}

} -
- - value={field.type} - onValueChange={(v) => v && updateField(field.localId, { type: v })} - > - - - - - {FIELD_TYPES.map((t) => ( - {t} - ))} - - -
- updateField(field.localId, { required: checked })} +
+ {input.source === "Stock" ? ( + + value={input.itemId} + onValueChange={(v) => v && pickInputItem(input, v)} + > + + + + + {items.map((i) => ( + + {i.name} · {i.sku} + + ))} + + + ) : ( + + value={input.fromOutputKey} + onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })} + > + + + + + {upstreamOptions.map((o) => ( + + {o.stageName} — {o.outputName} + + ))} + + + )} + + updateInput(input.localId, { qtyPerBatch })} + onUomChange={(uomId) => updateInput(input.localId, { uomId })} /> - Required
- {field.type === "Select" && ( - 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" - /> - )} -
- ))} + ))} +
-
- {!readOnly && ( - - )} -
-
+ {/* Outputs */} +
+
+

+ Outputs{isTerminal && (terminal — finished good)} +

+ {!readOnly && ( + + )} +
+
+ {data.outputs.length === 0 &&

No outputs yet.

} + {data.outputs.map((output) => ( +
+
+ {isTerminal ? ( + value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}> + + + + + {items.map((i) => ( + + {i.name} · {i.sku} + + ))} + + + ) : ( + updateOutput(output.key, { name: e.target.value })} + placeholder="Output name (work in progress)" + className="h-8 flex-1 text-sm" + /> + )} + {!readOnly && ( + + )} +
+ updateOutput(output.key, { qtyPerBatch })} + onUomChange={(uomId) => updateOutput(output.key, { uomId })} + /> +
+ ))} +
+
+ + {/* Custom fields */} +
+
+

Custom fields

+ {!readOnly && ( + + )} +
+
+ {data.fieldDefs.length === 0 &&

No custom fields.

} + {data.fieldDefs.map((field) => ( +
+
+ updateField(field.localId, { label: e.target.value })} + placeholder="Label" + className="h-8 flex-1 text-sm" + /> + {!readOnly && ( + + )} +
+ {field.key &&

key: {field.key}

} +
+ + value={field.type} + onValueChange={(v) => v && updateField(field.localId, { type: v })} + > + + + + + {FIELD_TYPES.map((t) => ( + {t} + ))} + + +
+ updateField(field.localId, { required: checked })} + /> + Required +
+
+ {field.type === "Select" && ( + 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" + /> + )} +
+ ))} +
+
+ + {!readOnly && ( + + )} +
+ +
) } diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx index 8a19847..2f666a4 100644 --- a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx @@ -768,46 +768,44 @@ export default function TemplateBuilderPage() {
)} -
-
- {mounted && ( - - - - - - )} -
- - {selectedNode && ( - updateNodeData(selectedNode.id, patch)} - onDelete={() => deleteNode(selectedNode.id)} - onClose={() => setSelectedNodeId(null)} - /> +
+ {mounted && ( + + + + + )}
+ + {selectedNode && ( + updateNodeData(selectedNode.id, patch)} + onDelete={() => deleteNode(selectedNode.id)} + onClose={() => setSelectedNodeId(null)} + /> + )}
) } diff --git a/Frontend/erp-system/app/dashboard/vendors/page.tsx b/Frontend/erp-system/app/dashboard/vendors/page.tsx index c3afcbf..fc34a81 100644 --- a/Frontend/erp-system/app/dashboard/vendors/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/page.tsx @@ -5,8 +5,9 @@ import Link from "next/link" import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react" 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 { generateVendorCode } from "@/lib/vendor-code" import { EntityStatus, PaginationMeta } from "@/types/common" import { Vendor } from "@/types/master-data" @@ -44,7 +45,6 @@ export default function VendorsPage() { const [page, setPage] = useState(1) const [open, setOpen] = useState(false) - const [code, setCode] = useState("") const [name, setName] = useState("") const [terms, setTerms] = useState("") const [taxReg, setTaxReg] = useState("") @@ -52,8 +52,14 @@ export default function VendorsPage() { const [errors, setErrors] = useState>({}) 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([]) + const [actionPendingId, setActionPendingId] = useState(null) + const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : "" + useEffect(() => { const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) return () => clearTimeout(timeout) @@ -75,8 +81,11 @@ export default function VendorsPage() { useEffect(load, [query, status, page]) + useEffect(() => { + vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {}) + }, []) + function resetForm() { - setCode("") setName("") setTerms("") setTaxReg("") @@ -86,7 +95,6 @@ export default function VendorsPage() { async function handleCreate() { const nextErrors: Record = {} - if (!code.trim()) nextErrors.code = "Vendor code is required" if (!name.trim()) nextErrors.name = "Vendor name is required" if (!currency.trim()) nextErrors.currency = "Currency is required" setErrors(nextErrors) @@ -94,14 +102,15 @@ export default function VendorsPage() { setSubmitting(true) 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}`) setOpen(false) resetForm() load() + setAllVendorCodes((codes) => [...codes, result.data.code]) } catch (err) { - const fe = fieldErrors(err) - if (fe?.code) setErrors({ code: fe.code }) + // A 409 here means another creation raced ours for the same generated code — the + // proactive de-dupe above only knows about codes loaded when the dialog opened. toast.error("Could not create vendor", errorMessage(err)) } finally { setSubmitting(false) @@ -145,19 +154,18 @@ export default function VendorsPage() { New vendor - Create a supplier record. + Create a supplier record. Its code is generated from the name. - - Code - setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} /> - - Name setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} /> + + Code (auto-generated) + + Payment terms (optional) setTerms(e.target.value)} placeholder="NET30" /> diff --git a/Frontend/erp-system/lib/vendor-code.ts b/Frontend/erp-system/lib/vendor-code.ts new file mode 100644 index 0000000..9908f8d --- /dev/null +++ b/Frontend/erp-system/lib/vendor-code.ts @@ -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}` +}