This commit is contained in:
2026-03-15 00:29:41 -05:00
parent f66001e514
commit 3323435114
22 changed files with 1376 additions and 8 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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" },
];

View File

@@ -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<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);
},
getPurchaseVendors(token: string) {
return request<PurchaseVendorOptionDto[]>("/api/v1/purchasing/vendors/options", undefined, token);
},
getQuotes(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
return request<SalesDocumentSummaryDto[]>(
`/api/v1/sales/quotes${buildQueryString({
@@ -434,6 +444,32 @@ export const api = {
token
);
},
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus }) {
return request<PurchaseOrderSummaryDto[]>(
`/api/v1/purchasing/orders${buildQueryString({
q: filters?.q,
status: filters?.status,
})}`,
undefined,
token
);
},
getPurchaseOrder(token: string, orderId: string) {
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, undefined, token);
},
createPurchaseOrder(token: string, payload: PurchaseOrderInput) {
return request<PurchaseOrderDetailDto>("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token);
},
updatePurchaseOrder(token: string, orderId: string, payload: PurchaseOrderInput) {
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updatePurchaseOrderStatus(token: string, orderId: string, status: PurchaseOrderStatus) {
return request<PurchaseOrderDetailDto>(
`/api/v1/purchasing/orders/${orderId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) },
token
);
},
getShipmentOrderOptions(token: string) {
return request<ShipmentOrderOptionDto[]>("/api/v1/shipping/orders/options", undefined, token);
},

View File

@@ -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: <WarehouseDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
children: [
{ path: "/purchasing/orders", element: <PurchaseOrdersPage /> },
{ path: "/purchasing/orders/:orderId", element: <PurchaseDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
children: [
@@ -88,6 +99,13 @@ const router = createBrowserRouter([
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
children: [
{ path: "/purchasing/orders/new", element: <PurchaseFormPage mode="create" /> },
{ path: "/purchasing/orders/:orderId/edit", element: <PurchaseFormPage mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />,
children: [

View File

@@ -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<PurchaseOrderDetailDto | null>(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 <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
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 (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Order</p>
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
<div className="mt-3 flex flex-wrap gap-2">
<PurchaseStatusBadge status={activeDocument.status} />
</div>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to purchase orders
</Link>
{canManage ? (
<Link to={`/purchasing/orders/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
Edit purchase order
</Link>
) : null}
</div>
</div>
</div>
{canManage ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Update purchase-order status without opening the full editor.</p>
</div>
<div className="flex flex-wrap gap-2">
{purchaseStatusOptions.map((option) => (
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || activeDocument.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{option.label}
</button>
))}
</div>
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p><div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p><div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div></article>
</section>
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p><div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p><div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Payment Terms</p><div className="mt-2 text-base font-bold text-text">{activeDocument.paymentTerms || "N/A"}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Currency</p><div className="mt-2 text-base font-bold text-text">{activeDocument.currencyCode || "USD"}</div></article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorName}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorEmail}</dd></div>
</dl>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
</article>
</div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
{activeDocument.lines.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No line items have been added yet.</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Qty</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
<tr key={line.id}>
<td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td>
<td className="px-2 py-2 text-muted">{line.description}</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
<td className="px-2 py-2 text-muted">${line.unitCost.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section>
);
}

View File

@@ -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<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(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 extends keyof PurchaseOrderInput>(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<HTMLFormElement>) {
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 (
<form className="space-y-6" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
</div>
<Link to={mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-4">
<label className="block xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Vendor</span>
<div className="relative">
<input
value={vendorSearchTerm}
onChange={(event) => {
setVendorSearchTerm(event.target.value);
updateField("vendorId", "");
setVendorPickerOpen(true);
}}
onFocus={() => setVendorPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setVendorPickerOpen(false);
if (form.vendorId) {
setVendorSearchTerm(getSelectedVendorName(form.vendorId));
}
}, 120);
}}
placeholder="Search vendor"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{vendorPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{vendors
.filter((vendor) => {
const query = vendorSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((vendor) => (
<button
key={vendor.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("vendorId", vendor.id);
setVendorSearchTerm(vendor.name);
setVendorPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{vendor.name}</div>
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
</button>
))}
{filteredVendorCount === 0 ? <div className="px-2 py-2 text-sm text-muted">No matching vendors found.</div> : null}
</div>
) : null}
</div>
<div className="mt-2 min-h-5 text-xs text-muted">{form.vendorId ? getSelectedVendorName(form.vendorId) : "No vendor selected"}</div>
{form.vendorId ? (
<div className="mt-1 text-xs text-muted">
Terms: {getSelectedVendor(form.vendorId)?.paymentTerms || "N/A"} | Currency: {getSelectedVendor(form.vendorId)?.currencyCode || "USD"}
</div>
) : null}
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={form.status} onChange={(event) => updateField("status", event.target.value as PurchaseOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{purchaseStatusOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Issue date</span>
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
<input type="number" min={0} max={100} step={0.01} value={form.taxPercent} onChange={(event) => updateField("taxPercent", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Freight</span>
<input type="number" min={0} step={0.01} value={form.freightAmount} onChange={(event) => updateField("freightAmount", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
<h4 className="mt-2 text-lg font-bold text-text">Procurement lines</h4>
</div>
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add line</button>
</div>
{form.lines.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No line items added yet.</div>
) : (
<div className="mt-5 space-y-4">
{form.lines.map((line: PurchaseLineInput, index: number) => (
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
<div className="relative">
<input
value={lineSearchTerms[index] ?? ""}
onChange={(event) => {
updateLineSearchTerm(index, event.target.value);
updateLine(index, { ...line, itemId: "" });
setActiveLinePicker(index);
}}
onFocus={() => setActiveLinePicker(index)}
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
placeholder="Search by SKU"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{activeLinePicker === index ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{itemOptions
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
.slice(0, 12)
.map((option) => (
<button
key={option.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateLine(index, {
...line,
itemId: option.id,
description: line.description || option.name,
});
updateLineSearchTerm(index, option.sku);
setActiveLinePicker(null);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
>
{option.sku}
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
<select value={line.unitOfMeasure} onChange={(event) => updateLine(index, { ...line, unitOfMeasure: event.target.value as PurchaseLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
{inventoryUnitOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Unit Cost</span>
<input type="number" min={0} step={0.01} value={line.unitCost} onChange={(event) => updateLine(index, { ...line, unitCost: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end"><div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">${(line.quantity * line.unitCost).toFixed(2)}</div></div>
<div className="flex items-end"><button type="button" onClick={() => removeLine(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">Remove</button></div>
</div>
</div>
))}
</div>
)}
<div className="mt-5 grid gap-3 md:grid-cols-3 xl:grid-cols-4">
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div><div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div></div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Tax</div><div className="mt-1 font-semibold text-text">${taxAmount.toFixed(2)}</div></div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Freight</div><div className="mt-1 font-semibold text-text">${form.freightAmount.toFixed(2)}</div></div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div><div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div></div>
</div>
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? "Create purchase order" : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,99 @@
import { permissions } from "@mrp/shared";
import type { PurchaseOrderStatus, PurchaseOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { purchaseStatusFilters } from "./config";
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
export function PurchaseListPage() {
const { token, user } = useAuth();
const [documents, setDocuments] = useState<PurchaseOrderSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | PurchaseOrderStatus>("ALL");
const [status, setStatus] = useState("Loading purchase orders...");
const canManage = user?.permissions.includes("purchasing.write") ?? false;
useEffect(() => {
if (!token) {
return;
}
api.getPurchaseOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
.then((nextDocuments) => {
setDocuments(nextDocuments);
setStatus(`${nextDocuments.length} purchase orders matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load purchase orders.";
setStatus(message);
});
}, [searchTerm, statusFilter, token]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing</p>
<h3 className="mt-2 text-lg font-bold text-text">Purchase Orders</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Vendor-facing procurement documents for material replenishment and bought-in components.</p>
</div>
{canManage ? (
<Link to="/purchasing/orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New purchase order
</Link>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input value={searchTerm} onChange={(event) => setSearchTerm(event.target.value)} placeholder="Search purchase orders by document number or vendor" className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | PurchaseOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
{purchaseStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{documents.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase orders have been added yet.</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Document</th>
<th className="px-2 py-2">Vendor</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Issue Date</th>
<th className="px-2 py-2">Value</th>
<th className="px-2 py-2">Lines</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{documents.map((document) => (
<tr key={document.id} className="transition hover:bg-page/70">
<td className="px-2 py-2"><Link to={`/purchasing/orders/${document.id}`} className="font-semibold text-text hover:text-brand">{document.documentNumber}</Link></td>
<td className="px-2 py-2 text-muted">{document.vendorName}</td>
<td className="px-2 py-2"><PurchaseStatusBadge status={document.status} /></td>
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export function PurchaseOrdersPage() {
return <Outlet />;
}

View File

@@ -0,0 +1,7 @@
import type { PurchaseOrderStatus } from "@mrp/shared";
import { purchaseStatusPalette } from "./config";
export function PurchaseStatusBadge({ status }: { status: PurchaseOrderStatus }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${purchaseStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
}

View File

@@ -0,0 +1,30 @@
import type { PurchaseOrderInput, PurchaseOrderStatus } from "@mrp/shared";
export const purchaseStatusOptions: Array<{ value: PurchaseOrderStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "ISSUED", label: "Issued" },
{ value: "APPROVED", label: "Approved" },
{ value: "CLOSED", label: "Closed" },
];
export const purchaseStatusFilters: Array<{ value: "ALL" | PurchaseOrderStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...purchaseStatusOptions,
];
export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
vendorId: "",
status: "DRAFT",
issueDate: new Date().toISOString(),
taxPercent: 0,
freightAmount: 0,
notes: "",
lines: [],
};

View File

@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "PurchaseOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"vendorId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"taxPercent" REAL NOT NULL DEFAULT 0,
"freightAmount" REAL NOT NULL DEFAULT 0,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrder_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "PurchaseOrderLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"purchaseOrderId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitCost" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PurchaseOrder_documentNumber_key" ON "PurchaseOrder"("documentNumber");
-- CreateIndex
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_position_idx" ON "PurchaseOrderLine"("purchaseOrderId", "position");

View File

@@ -122,6 +122,7 @@ model InventoryItem {
inventoryTransactions InventoryTransaction[]
salesQuoteLines SalesQuoteLine[]
salesOrderLines SalesOrderLine[]
purchaseOrderLines PurchaseOrderLine[]
}
model Warehouse {
@@ -250,6 +251,7 @@ model Vendor {
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
contacts CrmContact[]
purchaseOrders PurchaseOrder[]
}
model CrmContactEntry {
@@ -368,3 +370,35 @@ model Shipment {
@@index([salesOrderId, createdAt])
}
model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique
vendorId String
status String
issueDate DateTime
taxPercent Float @default(0)
freightAmount Float @default(0)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
lines PurchaseOrderLine[]
}
model PurchaseOrderLine {
id String @id @default(cuid())
purchaseOrderId String
itemId String
description String
quantity Int
unitOfMeasure String
unitCost Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([purchaseOrderId, position])
}

View File

@@ -17,6 +17,7 @@ import { documentsRouter } from "./modules/documents/router.js";
import { filesRouter } from "./modules/files/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { purchasingRouter } from "./modules/purchasing/router.js";
import { salesRouter } from "./modules/sales/router.js";
import { shippingRouter } from "./modules/shipping/router.js";
import { settingsRouter } from "./modules/settings/router.js";
@@ -54,6 +55,7 @@ export function createApp() {
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/purchasing", purchasingRouter);
app.use("/api/v1/sales", salesRouter);
app.use("/api/v1/shipping", shippingRouter);
app.use("/api/v1/gantt", ganttRouter);

View File

@@ -18,6 +18,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.ganttRead]: "View gantt timelines",
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
"purchasing.read": "View purchasing data",
"purchasing.write": "Manage purchase orders",
[permissions.shippingRead]: "View shipping data",
[permissions.shippingWrite]: "Manage shipments",
};

View File

@@ -0,0 +1,128 @@
import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { purchaseOrderStatuses } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createPurchaseOrder,
getPurchaseOrderById,
listPurchaseOrders,
listPurchaseVendorOptions,
updatePurchaseOrder,
updatePurchaseOrderStatus,
} from "./service.js";
const purchaseLineSchema = z.object({
itemId: z.string().trim().min(1),
description: z.string(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
unitCost: z.number().nonnegative(),
position: z.number().int().nonnegative(),
});
const purchaseOrderSchema = z.object({
vendorId: z.string().trim().min(1),
status: z.enum(purchaseOrderStatuses),
issueDate: z.string().datetime(),
taxPercent: z.number().min(0).max(100),
freightAmount: z.number().nonnegative(),
notes: z.string(),
lines: z.array(purchaseLineSchema),
});
const purchaseListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(purchaseOrderStatuses).optional(),
});
const purchaseStatusUpdateSchema = z.object({
status: z.enum(purchaseOrderStatuses),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const purchasingRouter = Router();
purchasingRouter.get("/vendors/options", requirePermissions(["purchasing.read"]), async (_request, response) => {
return ok(response, await listPurchaseVendorOptions());
});
purchasingRouter.get("/orders", requirePermissions(["purchasing.read"]), async (request, response) => {
const parsed = purchaseListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order filters are invalid.");
}
return ok(response, await listPurchaseOrders(parsed.data));
});
purchasingRouter.get("/orders/:orderId", requirePermissions(["purchasing.read"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const order = await getPurchaseOrderById(orderId);
if (!order) {
return fail(response, 404, "PURCHASE_ORDER_NOT_FOUND", "Purchase order was not found.");
}
return ok(response, order);
});
purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => {
const parsed = purchaseOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
}
const result = await createPurchaseOrder(parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
purchasingRouter.put("/orders/:orderId", requirePermissions(["purchasing.write"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const parsed = purchaseOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
}
const result = await updatePurchaseOrder(orderId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
purchasingRouter.patch("/orders/:orderId/status", requirePermissions(["purchasing.write"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const parsed = purchaseStatusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order status payload is invalid.");
}
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});

View File

@@ -0,0 +1,358 @@
import type {
PurchaseLineInput,
PurchaseOrderDetailDto,
PurchaseOrderInput,
PurchaseOrderStatus,
PurchaseOrderSummaryDto,
PurchaseVendorOptionDto,
} from "@mrp/shared";
import { prisma } from "../../lib/prisma.js";
const purchaseOrderModel = (prisma as any).purchaseOrder;
type PurchaseLineRecord = {
id: string;
description: string;
quantity: number;
unitOfMeasure: string;
unitCost: number;
position: number;
item: {
id: string;
sku: string;
name: string;
};
};
type PurchaseOrderRecord = {
id: string;
documentNumber: string;
status: string;
issueDate: Date;
taxPercent: number;
freightAmount: number;
notes: string;
createdAt: Date;
updatedAt: Date;
vendor: {
id: string;
name: string;
email: string;
paymentTerms: string | null;
currencyCode: string | null;
};
lines: PurchaseLineRecord[];
};
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function calculateTotals(subtotal: number, taxPercent: number, freightAmount: number) {
const normalizedSubtotal = roundMoney(subtotal);
const normalizedTaxPercent = Number.isFinite(taxPercent) ? taxPercent : 0;
const normalizedFreight = roundMoney(Number.isFinite(freightAmount) ? freightAmount : 0);
const taxAmount = roundMoney(normalizedSubtotal * (normalizedTaxPercent / 100));
const total = roundMoney(normalizedSubtotal + taxAmount + normalizedFreight);
return {
subtotal: normalizedSubtotal,
taxPercent: normalizedTaxPercent,
taxAmount,
freightAmount: normalizedFreight,
total,
};
}
function normalizeLines(lines: PurchaseLineInput[]) {
return lines
.map((line, index) => ({
itemId: line.itemId,
description: line.description.trim(),
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
unitCost: Number(line.unitCost),
position: line.position ?? (index + 1) * 10,
}))
.filter((line) => line.itemId.trim().length > 0);
}
async function validateLines(lines: PurchaseLineInput[]) {
const normalized = normalizeLines(lines);
if (normalized.length === 0) {
return { ok: false as const, reason: "At least one line item is required." };
}
if (normalized.some((line) => !Number.isInteger(line.quantity) || line.quantity <= 0)) {
return { ok: false as const, reason: "Line quantity must be a whole number greater than zero." };
}
if (normalized.some((line) => Number.isNaN(line.unitCost) || line.unitCost < 0)) {
return { ok: false as const, reason: "Unit cost must be zero or greater." };
}
const itemIds = [...new Set(normalized.map((line) => line.itemId))];
const items = await prisma.inventoryItem.findMany({
where: { id: { in: itemIds } },
select: { id: true, isPurchasable: true },
});
if (items.length !== itemIds.length) {
return { ok: false as const, reason: "One or more purchase lines reference an invalid inventory item." };
}
if (items.some((item) => !item.isPurchasable)) {
return { ok: false as const, reason: "Purchase orders can only include purchasable inventory items." };
}
return { ok: true as const, lines: normalized };
}
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
const lines = record.lines
.slice()
.sort((left, right) => left.position - right.position)
.map((line) => ({
id: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure as PurchaseOrderDetailDto["lines"][number]["unitOfMeasure"],
unitCost: line.unitCost,
lineTotal: line.quantity * line.unitCost,
position: line.position,
}));
const totals = calculateTotals(
lines.reduce((sum, line) => sum + line.lineTotal, 0),
record.taxPercent,
record.freightAmount
);
return {
id: record.id,
documentNumber: record.documentNumber,
vendorId: record.vendor.id,
vendorName: record.vendor.name,
vendorEmail: record.vendor.email,
paymentTerms: record.vendor.paymentTerms,
currencyCode: record.vendor.currencyCode,
status: record.status as PurchaseOrderStatus,
subtotal: totals.subtotal,
taxPercent: totals.taxPercent,
taxAmount: totals.taxAmount,
freightAmount: totals.freightAmount,
total: totals.total,
issueDate: record.issueDate.toISOString(),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
lineCount: lines.length,
lines,
};
}
async function nextDocumentNumber() {
const next = (await purchaseOrderModel.count()) + 1;
return `PO-${String(next).padStart(5, "0")}`;
}
function buildInclude() {
return {
vendor: {
select: {
id: true,
name: true,
email: true,
paymentTerms: true,
currencyCode: true,
},
},
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
};
}
export async function listPurchaseVendorOptions(): Promise<PurchaseVendorOptionDto[]> {
const vendors = await prisma.vendor.findMany({
where: {
status: {
not: "INACTIVE",
},
},
select: {
id: true,
name: true,
email: true,
paymentTerms: true,
currencyCode: true,
},
orderBy: [{ name: "asc" }],
});
return vendors;
}
export async function listPurchaseOrders(filters: { q?: string; status?: PurchaseOrderStatus } = {}) {
const query = filters.q?.trim();
const records = await purchaseOrderModel.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(query
? {
OR: [
{ documentNumber: { contains: query } },
{ vendor: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(),
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return records.map((record: unknown) => {
const detail = mapPurchaseOrder(record as PurchaseOrderRecord);
const summary: PurchaseOrderSummaryDto = {
id: detail.id,
documentNumber: detail.documentNumber,
vendorId: detail.vendorId,
vendorName: detail.vendorName,
status: detail.status,
subtotal: detail.subtotal,
taxPercent: detail.taxPercent,
taxAmount: detail.taxAmount,
freightAmount: detail.freightAmount,
total: detail.total,
issueDate: detail.issueDate,
updatedAt: detail.updatedAt,
lineCount: detail.lineCount,
};
return summary;
});
}
export async function getPurchaseOrderById(documentId: string) {
const record = await purchaseOrderModel.findUnique({
where: { id: documentId },
include: buildInclude(),
});
return record ? mapPurchaseOrder(record as PurchaseOrderRecord) : null;
}
export async function createPurchaseOrder(payload: PurchaseOrderInput) {
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Vendor was not found." };
}
const documentNumber = await nextDocumentNumber();
const created = await purchaseOrderModel.create({
data: {
documentNumber,
vendorId: payload.vendorId,
status: payload.status,
issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
lines: {
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getPurchaseOrderById(created.id);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
}
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput) {
const existing = await purchaseOrderModel.findUnique({
where: { id: documentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Purchase order was not found." };
}
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Vendor was not found." };
}
await purchaseOrderModel.update({
where: { id: documentId },
data: {
vendorId: payload.vendorId,
status: payload.status,
issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
lines: {
deleteMany: {},
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getPurchaseOrderById(documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
}
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus) {
const existing = await purchaseOrderModel.findUnique({
where: { id: documentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Purchase order was not found." };
}
await purchaseOrderModel.update({
where: { id: documentId },
data: { status },
select: { id: true },
});
const detail = await getPurchaseOrderById(documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
}

View File

@@ -11,6 +11,8 @@ export const permissions = {
ganttRead: "gantt.read",
salesRead: "sales.read",
salesWrite: "sales.write",
purchasingRead: "purchasing.read",
purchasingWrite: "purchasing.write",
shippingRead: "shipping.read",
shippingWrite: "shipping.write",
} as const;

View File

@@ -6,5 +6,6 @@ export * from "./crm/types.js";
export * from "./files/types.js";
export * from "./gantt/types.js";
export * from "./inventory/types.js";
export * from "./purchasing/types.js";
export * from "./sales/types.js";
export * from "./shipping/types.js";

View File

@@ -0,0 +1,70 @@
import type { InventoryUnitOfMeasure } from "../inventory/types.js";
export const purchaseOrderStatuses = ["DRAFT", "ISSUED", "APPROVED", "CLOSED"] as const;
export type PurchaseOrderStatus = (typeof purchaseOrderStatuses)[number];
export interface PurchaseVendorOptionDto {
id: string;
name: string;
email: string;
paymentTerms: string | null;
currencyCode: string | null;
}
export interface PurchaseLineDto {
id: string;
itemId: string;
itemSku: string;
itemName: string;
description: string;
quantity: number;
unitOfMeasure: InventoryUnitOfMeasure;
unitCost: number;
lineTotal: number;
position: number;
}
export interface PurchaseLineInput {
itemId: string;
description: string;
quantity: number;
unitOfMeasure: InventoryUnitOfMeasure;
unitCost: number;
position: number;
}
export interface PurchaseOrderSummaryDto {
id: string;
documentNumber: string;
vendorId: string;
vendorName: string;
status: PurchaseOrderStatus;
subtotal: number;
taxPercent: number;
taxAmount: number;
freightAmount: number;
total: number;
issueDate: string;
updatedAt: string;
lineCount: number;
}
export interface PurchaseOrderDetailDto extends PurchaseOrderSummaryDto {
vendorEmail: string;
notes: string;
paymentTerms: string | null;
currencyCode: string | null;
createdAt: string;
lines: PurchaseLineDto[];
}
export interface PurchaseOrderInput {
vendorId: string;
status: PurchaseOrderStatus;
issueDate: string;
taxPercent: number;
freightAmount: number;
notes: string;
lines: PurchaseLineInput[];
}