diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index f9a02d6..33baaa4 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -11,6 +11,7 @@ This repository implements the platform foundation milestone: - CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata - inventory master data, BOM, warehouse, stock-location, transactions, and item attachments - sales quotes and sales orders with quick actions and quote conversion +- purchase orders with quick actions and searchable vendor/SKU entry - shipping shipments linked to sales orders and packing-slip PDFs - Dockerized single-container deployment - Puppeteer PDF pipeline foundation @@ -41,7 +42,7 @@ This repository implements the platform foundation milestone: ## Next roadmap candidates -- purchase orders and vendor-facing receiving flow +- purchase receiving flow and vendor-side operational depth - sales and purchasing document templates - shipping labels, bills of lading, and logistics attachments - manufacturing gantt scheduling with live project data diff --git a/README.md b/README.md index fa84f3f..f389880 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Current foundation scope includes: - CRM contact history, account contacts, and shared attachments - inventory item master, BOM, warehouse, stock-location, and stock-transaction flows - sales quotes and sales orders with searchable customer and SKU entry +- purchase orders with searchable vendor and SKU entry - shipping shipments linked to sales orders with packing-slip PDFs - file storage and PDF rendering @@ -21,13 +22,13 @@ Current completed foundation areas: - dashboard foundation - CRM foundation - inventory foundation -- sales foundation +- sales and purchasing foundation - shipping foundation - branding, attachments, auth/RBAC, and PDF infrastructure Near-term priorities: -1. Purchase orders and vendor-facing document flow +1. Purchase receiving flow and vendor-facing operational depth 2. Sales and purchasing PDF templates 3. Shipping labels, bills of lading, and logistics attachments 4. Live manufacturing gantt scheduling @@ -48,6 +49,7 @@ Dashboard direction: - it should remain a metric-oriented operational surface rather than a generic welcome page - new modules should add reusable dashboard cards/panels instead of replacing the whole layout - future additions should emphasize relevant metrics, next actions, alerts, and workflow shortcuts +- richer recent-activity widgets and exception queues are a planned QOL follow-up, not a separate landing-page redesign ## Workspace @@ -170,6 +172,26 @@ QOL direction: This module introduces `sales.read` and `sales.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role. +## Purchasing + +The current purchasing foundation supports: + +- purchase-order list, detail, create, and edit flows +- searchable vendor lookup instead of a static vendor dropdown +- SKU-searchable line entry for purchase-order lines +- document-level tax, freight, subtotal, and total calculations +- quick status actions directly from purchase-order detail pages +- vendor payment terms and currency surfaced on purchase-order forms and details + +QOL direction: + +- receiving workflow tied to purchase orders +- vendor invoice/supporting-document attachments +- purchasing PDFs through the shared document pipeline +- richer dashboard widgets for vendor queues and inbound material exceptions + +This module introduces `purchasing.read` and `purchasing.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role. + ## Shipping The current shipping foundation supports: @@ -226,6 +248,7 @@ As of March 14, 2026, the latest committed domain migrations include: - warehouse and stock-location foundation - inventory transactions and on-hand tracking - sales quote and sales-order foundation +- purchase-order foundation - inventory default price support - sales totals and commercial fields - shipping foundation diff --git a/ROADMAP.md b/ROADMAP.md index b4b9ecb..eedc1e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Inventory item master, BOM, warehouse, and stock-location foundation - Inventory transactions, on-hand tracking, and item attachments - Sales quotes and sales orders with commercial totals logic +- Purchase orders with vendor lookup, item lines, totals, and quick status actions - Shipping shipment records linked to sales orders - Packing-slip PDF rendering for shipments - SKU-searchable BOM component selection for inventory-scale datasets @@ -43,9 +44,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution - The frontend bundle is functional but should be code-split later, especially around the gantt module - CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later -- Purchasing documents are not yet implemented -- The current sales/shipping foundation still does not include approvals, revisions, shipment labels, or broader branded transactional PDF coverage beyond packing slips -- The dashboard is now metric-oriented, but still needs live module-fed KPIs, alerts, and exception reporting as more transactional depth is added +- The current sales/purchasing/shipping foundation still does not include approvals, revisions, receiving flow, shipment labels, or broader branded transactional PDF coverage beyond packing slips +- The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added ## Dashboard Plan @@ -103,6 +103,7 @@ QOL subfeatures: - Line duplication, drag ordering, and keyboard-first line editing - Saved customer defaults for tax, freight, and commercial terms - Inline stock visibility while building quotes and orders +- Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions - Better totals breakdown visibility on list pages and detail pages - Revision comparison view for changed customer-facing documents - Faster document cloning and quote-to-order style conversions across document types @@ -169,7 +170,7 @@ QOL subfeatures: - CRM document rollups and broader account-role depth were deferred until more downstream modules exist - Audit-trail depth is still thin outside the current record/update flows - Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use -- Dashboard cards are still mostly structural and should be revisited once more live operational data exists +- Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred ## Cross-cutting improvements @@ -182,7 +183,7 @@ QOL subfeatures: ## Near-term priority order -1. Purchase orders and vendor-facing document flow +1. Purchase receiving flow and vendor-side operational depth 2. Sales and purchasing PDF templates 3. Shipping labels, bills of lading, and logistics attachments 4. Live manufacturing gantt scheduling diff --git a/STRUCTURE.md b/STRUCTURE.md index ec7c68a..571f8bc 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -18,6 +18,7 @@ - Treat `src/modules/dashboard` as a long-lived operational module. New high-level KPI, alert, queue, and shortcut surfaces should compose into it rather than spawning disconnected landing pages. - Any non-filter lookup UI must be implemented as a searchable picker or autocomplete; do not use long static dropdowns for operational datasets such as items, customers, vendors, or document-linked records. - Inventory items expose both cost and sell price. Downstream sales document entry should default from the item price field rather than requiring duplicate price maintenance. +- Future vendor-facing purchasing flows should follow the same searchable-lookup rule and shared document/totals model already used by sales. - Shipping, sales, and future purchasing PDFs should be rendered through the backend documents module and shared Puppeteer pipeline rather than ad hoc frontend-only exports. - Preserve the current dense operations UI style on active module pages: compact controls, tighter card padding, and shorter empty states unless a screen has a clear reason to be more spacious. diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 7aa1631..97e5112 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -12,6 +12,7 @@ const links = [ { to: "/inventory/warehouses", label: "Warehouses" }, { to: "/sales/quotes", label: "Quotes" }, { to: "/sales/orders", label: "Sales Orders" }, + { to: "/purchasing/orders", label: "Purchase Orders" }, { to: "/shipping/shipments", label: "Shipments" }, { to: "/planning/gantt", label: "Gantt" }, ]; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 599aa21..fcb0592 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -40,6 +40,13 @@ import type { SalesDocumentStatus, SalesDocumentSummaryDto, } from "@mrp/shared/dist/sales/types.js"; +import type { + PurchaseOrderDetailDto, + PurchaseOrderInput, + PurchaseOrderStatus, + PurchaseOrderSummaryDto, + PurchaseVendorOptionDto, +} from "@mrp/shared"; import type { ShipmentDetailDto, ShipmentInput, @@ -379,6 +386,9 @@ export const api = { getSalesCustomers(token: string) { return request("/api/v1/sales/customers/options", undefined, token); }, + getPurchaseVendors(token: string) { + return request("/api/v1/purchasing/vendors/options", undefined, token); + }, getQuotes(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) { return request( `/api/v1/sales/quotes${buildQueryString({ @@ -434,6 +444,32 @@ export const api = { token ); }, + getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus }) { + return request( + `/api/v1/purchasing/orders${buildQueryString({ + q: filters?.q, + status: filters?.status, + })}`, + undefined, + token + ); + }, + getPurchaseOrder(token: string, orderId: string) { + return request(`/api/v1/purchasing/orders/${orderId}`, undefined, token); + }, + createPurchaseOrder(token: string, payload: PurchaseOrderInput) { + return request("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token); + }, + updatePurchaseOrder(token: string, orderId: string, payload: PurchaseOrderInput) { + return request(`/api/v1/purchasing/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token); + }, + updatePurchaseOrderStatus(token: string, orderId: string, status: PurchaseOrderStatus) { + return request( + `/api/v1/purchasing/orders/${orderId}/status`, + { method: "PATCH", body: JSON.stringify({ status }) }, + token + ); + }, getShipmentOrderOptions(token: string) { return request("/api/v1/shipping/orders/options", undefined, token); }, diff --git a/client/src/main.tsx b/client/src/main.tsx index 172382c..13cf455 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -18,6 +18,10 @@ import { GanttPage } from "./modules/gantt/GanttPage"; import { InventoryDetailPage } from "./modules/inventory/InventoryDetailPage"; import { InventoryFormPage } from "./modules/inventory/InventoryFormPage"; import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage"; +import { PurchaseDetailPage } from "./modules/purchasing/PurchaseDetailPage"; +import { PurchaseFormPage } from "./modules/purchasing/PurchaseFormPage"; +import { PurchaseListPage } from "./modules/purchasing/PurchaseListPage"; +import { PurchaseOrdersPage } from "./modules/purchasing/PurchaseOrdersPage"; import { WarehouseDetailPage } from "./modules/inventory/WarehouseDetailPage"; import { WarehouseFormPage } from "./modules/inventory/WarehouseFormPage"; import { WarehousesPage } from "./modules/inventory/WarehousesPage"; @@ -63,6 +67,13 @@ const router = createBrowserRouter([ { path: "/inventory/warehouses/:warehouseId", element: }, ], }, + { + element: , + children: [ + { path: "/purchasing/orders", element: }, + { path: "/purchasing/orders/:orderId", element: }, + ], + }, { element: , children: [ @@ -88,6 +99,13 @@ const router = createBrowserRouter([ { path: "/crm/vendors/:vendorId/edit", element: }, ], }, + { + element: , + children: [ + { path: "/purchasing/orders/new", element: }, + { path: "/purchasing/orders/:orderId/edit", element: }, + ], + }, { element: , children: [ diff --git a/client/src/modules/purchasing/PurchaseDetailPage.tsx b/client/src/modules/purchasing/PurchaseDetailPage.tsx new file mode 100644 index 0000000..429a43e --- /dev/null +++ b/client/src/modules/purchasing/PurchaseDetailPage.tsx @@ -0,0 +1,157 @@ +import { permissions } from "@mrp/shared"; +import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared"; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; +import { purchaseStatusOptions } from "./config"; +import { PurchaseStatusBadge } from "./PurchaseStatusBadge"; + +export function PurchaseDetailPage() { + const { token, user } = useAuth(); + const { orderId } = useParams(); + const [document, setDocument] = useState(null); + const [status, setStatus] = useState("Loading purchase order..."); + const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + + const canManage = user?.permissions.includes("purchasing.write") ?? false; + + useEffect(() => { + if (!token || !orderId) { + return; + } + + api.getPurchaseOrder(token, orderId) + .then((nextDocument) => { + setDocument(nextDocument); + setStatus("Purchase order loaded."); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load purchase order."; + setStatus(message); + }); + }, [orderId, token]); + + if (!document) { + return
{status}
; + } + + const activeDocument = document; + + async function handleStatusChange(nextStatus: PurchaseOrderStatus) { + if (!token) { + return; + } + + setIsUpdatingStatus(true); + setStatus("Updating purchase order status..."); + + try { + const nextDocument = await api.updatePurchaseOrderStatus(token, activeDocument.id, nextStatus); + setDocument(nextDocument); + setStatus("Purchase order status updated."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to update purchase order status."; + setStatus(message); + } finally { + setIsUpdatingStatus(false); + } + } + + return ( +
+
+
+
+

Purchase Order

+

{activeDocument.documentNumber}

+

{activeDocument.vendorName}

+
+ +
+
+
+ + Back to purchase orders + + {canManage ? ( + + Edit purchase order + + ) : null} +
+
+
+ {canManage ? ( +
+
+
+

Quick Actions

+

Update purchase-order status without opening the full editor.

+
+
+ {purchaseStatusOptions.map((option) => ( + + ))} +
+
+
+ ) : null} +
+

Issue Date

{new Date(activeDocument.issueDate).toLocaleDateString()}
+

Lines

{activeDocument.lineCount}
+

Subtotal

${activeDocument.subtotal.toFixed(2)}
+

Total

${activeDocument.total.toFixed(2)}
+
+
+

Tax

${activeDocument.taxAmount.toFixed(2)}
{activeDocument.taxPercent.toFixed(2)}%
+

Freight

${activeDocument.freightAmount.toFixed(2)}
+

Payment Terms

{activeDocument.paymentTerms || "N/A"}
+

Currency

{activeDocument.currencyCode || "USD"}
+
+
+
+

Vendor

+
+
Account
{activeDocument.vendorName}
+
Email
{activeDocument.vendorEmail}
+
+
+
+

Notes

+

{activeDocument.notes || "No notes recorded for this document."}

+
+
+
+

Line Items

+ {activeDocument.lines.length === 0 ? ( +
No line items have been added yet.
+ ) : ( +
+ + + + + + {activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => ( + + + + + + + + + ))} + +
ItemDescriptionQtyUOMUnit CostTotal
{line.itemSku}
{line.itemName}
{line.description}{line.quantity}{line.unitOfMeasure}${line.unitCost.toFixed(2)}${line.lineTotal.toFixed(2)}
+
+ )} +
+
{status}
+
+ ); +} diff --git a/client/src/modules/purchasing/PurchaseFormPage.tsx b/client/src/modules/purchasing/PurchaseFormPage.tsx new file mode 100644 index 0000000..720318f --- /dev/null +++ b/client/src/modules/purchasing/PurchaseFormPage.tsx @@ -0,0 +1,356 @@ +import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js"; +import type { PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared"; +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; +import { inventoryUnitOptions } from "../inventory/config"; +import { emptyPurchaseOrderInput, purchaseStatusOptions } from "./config"; + +export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) { + const { token } = useAuth(); + const navigate = useNavigate(); + const { orderId } = useParams(); + const [form, setForm] = useState(emptyPurchaseOrderInput); + const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order..."); + const [vendors, setVendors] = useState([]); + const [vendorSearchTerm, setVendorSearchTerm] = useState(""); + const [vendorPickerOpen, setVendorPickerOpen] = useState(false); + const [itemOptions, setItemOptions] = useState([]); + const [lineSearchTerms, setLineSearchTerms] = useState([]); + const [activeLinePicker, setActiveLinePicker] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0); + const taxAmount = subtotal * (form.taxPercent / 100); + const total = subtotal + taxAmount + form.freightAmount; + + useEffect(() => { + if (!token) { + return; + } + + api.getPurchaseVendors(token).then(setVendors).catch(() => setVendors([])); + api.getInventoryItemOptions(token).then(setItemOptions).catch(() => setItemOptions([])); + }, [token]); + + useEffect(() => { + if (!token || mode !== "edit" || !orderId) { + return; + } + + api.getPurchaseOrder(token, orderId) + .then((document) => { + setForm({ + vendorId: document.vendorId, + status: document.status, + issueDate: document.issueDate, + taxPercent: document.taxPercent, + freightAmount: document.freightAmount, + notes: document.notes, + lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number }) => ({ + itemId: line.itemId, + description: line.description, + quantity: line.quantity, + unitOfMeasure: line.unitOfMeasure, + unitCost: line.unitCost, + position: line.position, + })), + }); + setVendorSearchTerm(document.vendorName); + setLineSearchTerms(document.lines.map((line: { itemSku: string }) => line.itemSku)); + setStatus("Purchase order loaded."); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load purchase order."; + setStatus(message); + }); + }, [mode, orderId, token]); + + function updateField(key: Key, value: PurchaseOrderInput[Key]) { + setForm((current: PurchaseOrderInput) => ({ ...current, [key]: value })); + } + + function getSelectedVendorName(vendorId: string) { + return vendors.find((vendor) => vendor.id === vendorId)?.name ?? ""; + } + + function getSelectedVendor(vendorId: string) { + return vendors.find((vendor) => vendor.id === vendorId) ?? null; + } + + function updateLine(index: number, nextLine: PurchaseLineInput) { + setForm((current: PurchaseOrderInput) => ({ + ...current, + lines: current.lines.map((line: PurchaseLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)), + })); + } + + function updateLineSearchTerm(index: number, value: string) { + setLineSearchTerms((current) => { + const next = [...current]; + next[index] = value; + return next; + }); + } + + function addLine() { + setForm((current: PurchaseOrderInput) => ({ + ...current, + lines: [ + ...current.lines, + { + itemId: "", + description: "", + quantity: 1, + unitOfMeasure: "EA", + unitCost: 0, + position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: PurchaseLineInput) => line.position)) + 10, + }, + ], + })); + setLineSearchTerms((current) => [...current, ""]); + } + + function removeLine(index: number) { + setForm((current: PurchaseOrderInput) => ({ + ...current, + lines: current.lines.filter((_line: PurchaseLineInput, lineIndex: number) => lineIndex !== index), + })); + setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index)); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!token) { + return; + } + + setIsSaving(true); + setStatus("Saving purchase order..."); + + try { + const saved = mode === "create" ? await api.createPurchaseOrder(token, form) : await api.updatePurchaseOrder(token, orderId ?? "", form); + navigate(`/purchasing/orders/${saved.id}`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to save purchase order."; + setStatus(message); + setIsSaving(false); + } + } + + const filteredVendorCount = vendors.filter((vendor) => { + const query = vendorSearchTerm.trim().toLowerCase(); + if (!query) { + return true; + } + + return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query); + }).length; + + return ( +
+
+
+
+

Purchasing Editor

+

{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}

+
+ + Cancel + +
+
+
+
+ + + +
+