manufacturing stabilization

This commit is contained in:
2026-03-15 11:30:10 -05:00
parent 0596970b99
commit e2254d020e
15 changed files with 492 additions and 60 deletions

View File

@@ -17,6 +17,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments - CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing - inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
- sales quotes, sales orders, and purchase orders - sales quotes, sales orders, and purchase orders
- purchase-order supporting documents and vendor-side purchasing visibility
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments - shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and attachments - manufacturing work orders with project linkage, material issue posting, completion posting, and attachments
@@ -116,11 +117,11 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. Vendor invoice/supporting-document attachments and broader vendor-side operational depth 1. Sales approvals and document revision history
2. Sales approvals and document revision history 2. Planning and gantt scheduling with live project/manufacturing data
3. Planning and gantt scheduling with live project/manufacturing data 3. Inventory transfers, reservations, and deeper stock controls
4. Inventory transfers, reservations, and deeper stock controls 4. Broader audit-trail coverage and operational diagnostics
5. Broader audit-trail coverage and operational diagnostics 5. Code-splitting and bundle-size reduction
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -13,12 +13,18 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Manufacturing foundation with work orders, optional project linkage, work-order attachments, and app-shell navigation entry - Manufacturing foundation with work orders, optional project linkage, work-order attachments, and app-shell navigation entry
- BOM-based manufacturing requirement visibility plus material issue and completion posting through inventory transactions - BOM-based manufacturing requirement visibility plus material issue and completion posting through inventory transactions
- Dashboard manufacturing widgets for released, active, and overdue work visibility - Dashboard manufacturing widgets for released, active, and overdue work visibility
- Purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and procurement backup files
- Vendor-detail purchasing visibility with recent purchase-order activity and PO launch shortcuts
### Changed ### Changed
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping - The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
- The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects - The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects
- Roadmap and project docs now treat vendor invoice/supporting-document attachments and broader vendor-side operational depth as the next active priority after the manufacturing foundation slice - Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- Roadmap and project docs now treat sales approvals and document revision history as the next active priority after the vendor-document and stabilization pass
## 2026-03-15 ## 2026-03-15

View File

@@ -20,6 +20,7 @@ This repository implements the platform foundation milestone:
- purchase orders restricted to inventory items flagged as purchasable - purchase orders restricted to inventory items flagged as purchasable
- purchase receiving foundation with inventory posting and receipt history - purchase receiving foundation with inventory posting and receipt history
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline - branded sales and purchasing PDFs through the shared Puppeteer document pipeline
- purchase-order supporting documents and vendor-side purchasing visibility
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments - shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility - projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility
- manufacturing work orders with project linkage, material issue posting, completion posting, attachments, and dashboard visibility - manufacturing work orders with project linkage, material issue posting, completion posting, attachments, and dashboard visibility
@@ -58,8 +59,8 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- vendor invoice/supporting-document attachments and broader vendor-side operational depth
- sales approvals and document revision history - sales approvals and document revision history
- planning and gantt scheduling with live project/manufacturing data - planning and gantt scheduling with live project/manufacturing data
- inventory transfers, reservations, and deeper stock controls - inventory transfers, reservations, and deeper stock controls
- broader audit and operations maturity - broader audit and operations maturity
- code-splitting and bundle-size reduction

View File

@@ -19,6 +19,7 @@ Current foundation scope includes:
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
- purchase receiving with warehouse/location posting and receipt history against purchase orders - purchase receiving with warehouse/location posting and receipt history against purchase orders
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline - branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments - shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and work-order attachments - manufacturing work orders with project linkage, material issue posting, completion posting, and work-order attachments
@@ -43,11 +44,11 @@ Planned cross-module execution areas:
Near-term priorities: Near-term priorities:
1. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth 1. Sales approvals and revision history
2. Sales approvals and revision history 2. Planning and gantt scheduling with live project/manufacturing data
3. Planning and gantt scheduling with live project/manufacturing data 3. Inventory transfers, reservations, and deeper stock controls
4. Inventory transfers, reservations, and deeper stock controls 4. Broader audit-trail coverage and operational diagnostics
5. Broader audit-trail coverage and operational diagnostics 5. Code-splitting and bundle-size reduction
Revisit / deferred items: Revisit / deferred items:
@@ -246,8 +247,8 @@ The current purchasing foundation supports:
QOL direction: QOL direction:
- vendor invoice/supporting-document attachments
- richer dashboard widgets for vendor queues and inbound material exceptions - richer dashboard widgets for vendor queues and inbound material exceptions
- vendor-side exception tracking around acknowledgements, invoice matching, and receipt discrepancies
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. 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.

View File

@@ -43,6 +43,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage - Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
- Project list/detail/create/edit workflows and dashboard program widgets - Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments - Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity
- SKU-searchable BOM component selection for inventory-scale datasets - SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts - Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
@@ -56,7 +58,7 @@ 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 - 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 - 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 - CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, vendor-side attachment handling, or deeper carrier integration - The current sales/purchasing/shipping foundation still does not include approvals, revisions, vendor exception handling, or deeper carrier integration
- 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 - 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
- The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins - The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins
- The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins - The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins
@@ -115,6 +117,11 @@ QOL subfeatures:
- Branded PDF templates rendered through Puppeteer - Branded PDF templates rendered through Puppeteer
- Attachments for vendor invoices and supporting documents - Attachments for vendor invoices and supporting documents
Foundation slice shipped:
- Purchase-order supporting documents through the shared attachment pipeline
- Vendor-detail purchasing visibility for recent purchase-order activity
QOL subfeatures: QOL subfeatures:
- Line duplication, drag ordering, and keyboard-first line editing - Line duplication, drag ordering, and keyboard-first line editing
@@ -251,7 +258,6 @@ QOL subfeatures:
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper - Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
- Frontend bundle splitting is still deferred; the Vite chunk-size warning remains - Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
- Sales approvals and document revision history were planned but not yet built - Sales approvals and document revision history were planned but not yet built
- Vendor invoice/supporting-document attachments still need to be added
- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up - Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist - 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 - Audit-trail depth is still thin outside the current record/update flows
@@ -269,8 +275,8 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. Vendor invoice/supporting-document attachments and broader vendor-side operational depth 1. Sales approvals and document revision history
2. Sales approvals and document revision history 2. Planning and scheduling with live project/manufacturing data
3. Planning and scheduling with live project/manufacturing data 3. Inventory transfers, reservations, and deeper stock controls
4. Inventory transfers, reservations, and deeper stock controls 4. Broader audit-trail coverage and operational diagnostics
5. Broader audit-trail coverage and operational diagnostics 5. Code-splitting and bundle-size reduction

View File

@@ -566,11 +566,12 @@ export const api = {
token token
); );
}, },
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus }) { getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) {
return request<PurchaseOrderSummaryDto[]>( return request<PurchaseOrderSummaryDto[]>(
`/api/v1/purchasing/orders${buildQueryString({ `/api/v1/purchasing/orders${buildQueryString({
q: filters?.q, q: filters?.q,
status: filters?.status, status: filters?.status,
vendorId: filters?.vendorId,
})}`, })}`,
undefined, undefined,
token token

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js"; import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
import type { PurchaseOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -23,6 +24,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
const recordId = entity === "customer" ? customerId : vendorId; const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity]; const config = crmConfigs[entity];
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null); const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
const [relatedPurchaseOrders, setRelatedPurchaseOrders] = useState<PurchaseOrderSummaryDto[]>([]);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`); const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput); const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput);
const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account."); const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account.");
@@ -42,7 +44,13 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
setRecord(nextRecord); setRecord(nextRecord);
setStatus(`${config.singularLabel} record loaded.`); setStatus(`${config.singularLabel} record loaded.`);
setContactEntryStatus("Add a timeline entry for this account."); setContactEntryStatus("Add a timeline entry for this account.");
if (entity === "vendor") {
return api.getPurchaseOrders(token, { vendorId: nextRecord.id });
}
return [];
}) })
.then((purchaseOrders) => setRelatedPurchaseOrders(purchaseOrders))
.catch((error: unknown) => { .catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`; const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message); setStatus(message);
@@ -250,6 +258,43 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div> </div>
</section> </section>
) : null} ) : null}
{entity === "vendor" ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Activity</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
</div>
<div className="flex flex-wrap gap-2">
{canManage ? (
<Link to={`/purchasing/orders/new?vendorId=${record.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New purchase order
</Link>
) : null}
<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">
Open purchasing
</Link>
</div>
</div>
{relatedPurchaseOrders.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 exist for this vendor yet.</div>
) : (
<div className="mt-6 space-y-3">
{relatedPurchaseOrders.slice(0, 8).map((order) => (
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} · {order.lineCount} lines</div>
</div>
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
</div>
</Link>
))}
</div>
)}
</section>
) : null}
<CrmContactsPanel <CrmContactsPanel
entity={entity} entity={entity}
ownerId={record.id} ownerId={record.id}

View File

@@ -10,6 +10,7 @@ interface DashboardSnapshot {
vendors: Awaited<ReturnType<typeof api.getVendors>> | null; vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null; items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null; warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
purchaseOrders: Awaited<ReturnType<typeof api.getPurchaseOrders>> | null;
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null; workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null; quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null; orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
@@ -69,6 +70,7 @@ export function DashboardPage() {
const canReadCrm = hasPermission(user.permissions, permissions.crmRead); const canReadCrm = hasPermission(user.permissions, permissions.crmRead);
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead); const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
const canReadPurchasing = hasPermission(user.permissions, permissions.purchasingRead);
const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead); const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
const canReadSales = hasPermission(user.permissions, permissions.salesRead); const canReadSales = hasPermission(user.permissions, permissions.salesRead);
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead); const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
@@ -80,6 +82,7 @@ export function DashboardPage() {
canReadCrm ? api.getVendors(authToken) : Promise.resolve(null), canReadCrm ? api.getVendors(authToken) : Promise.resolve(null),
canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null), canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null),
canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null), canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null),
canReadPurchasing ? api.getPurchaseOrders(authToken) : Promise.resolve(null),
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null), canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null), canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null), canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
@@ -102,11 +105,12 @@ export function DashboardPage() {
vendors: results[1].status === "fulfilled" ? results[1].value : null, vendors: results[1].status === "fulfilled" ? results[1].value : null,
items: results[2].status === "fulfilled" ? results[2].value : null, items: results[2].status === "fulfilled" ? results[2].value : null,
warehouses: results[3].status === "fulfilled" ? results[3].value : null, warehouses: results[3].status === "fulfilled" ? results[3].value : null,
workOrders: results[4].status === "fulfilled" ? results[4].value : null, purchaseOrders: results[4].status === "fulfilled" ? results[4].value : null,
quotes: results[5].status === "fulfilled" ? results[5].value : null, workOrders: results[5].status === "fulfilled" ? results[5].value : null,
orders: results[6].status === "fulfilled" ? results[6].value : null, quotes: results[6].status === "fulfilled" ? results[6].value : null,
shipments: results[7].status === "fulfilled" ? results[7].value : null, orders: results[7].status === "fulfilled" ? results[7].value : null,
projects: results[8].status === "fulfilled" ? results[8].value : null, shipments: results[8].status === "fulfilled" ? results[8].value : null,
projects: results[9].status === "fulfilled" ? results[9].value : null,
refreshedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(),
}); });
setIsLoading(false); setIsLoading(false);
@@ -131,6 +135,7 @@ export function DashboardPage() {
const vendors = snapshot?.vendors ?? []; const vendors = snapshot?.vendors ?? [];
const items = snapshot?.items ?? []; const items = snapshot?.items ?? [];
const warehouses = snapshot?.warehouses ?? []; const warehouses = snapshot?.warehouses ?? [];
const purchaseOrders = snapshot?.purchaseOrders ?? [];
const workOrders = snapshot?.workOrders ?? []; const workOrders = snapshot?.workOrders ?? [];
const quotes = snapshot?.quotes ?? []; const quotes = snapshot?.quotes ?? [];
const orders = snapshot?.orders ?? []; const orders = snapshot?.orders ?? [];
@@ -140,6 +145,7 @@ export function DashboardPage() {
const accessibleModules = [ const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null, snapshot?.customers !== null || snapshot?.vendors !== null,
snapshot?.items !== null || snapshot?.warehouses !== null, snapshot?.items !== null || snapshot?.warehouses !== null,
snapshot?.purchaseOrders !== null,
snapshot?.workOrders !== null, snapshot?.workOrders !== null,
snapshot?.quotes !== null || snapshot?.orders !== null, snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null, snapshot?.shipments !== null,
@@ -159,6 +165,11 @@ export function DashboardPage() {
const warehouseCount = warehouses.length; const warehouseCount = warehouses.length;
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount)); const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
const purchaseOrderCount = purchaseOrders.length;
const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length;
const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total));
const workOrderCount = workOrders.length; const workOrderCount = workOrders.length;
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length; const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length; const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
@@ -192,6 +203,7 @@ export function DashboardPage() {
...vendors.map((vendor) => vendor.updatedAt), ...vendors.map((vendor) => vendor.updatedAt),
...items.map((item) => item.updatedAt), ...items.map((item) => item.updatedAt),
...warehouses.map((warehouse) => warehouse.updatedAt), ...warehouses.map((warehouse) => warehouse.updatedAt),
...purchaseOrders.map((order) => order.updatedAt),
...workOrders.map((workOrder) => workOrder.updatedAt), ...workOrders.map((workOrder) => workOrder.updatedAt),
...quotes.map((quote) => quote.updatedAt), ...quotes.map((quote) => quote.updatedAt),
...orders.map((order) => order.updatedAt), ...orders.map((order) => order.updatedAt),
@@ -220,6 +232,15 @@ export function DashboardPage() {
: "Inventory metrics are permission-gated.", : "Inventory metrics are permission-gated.",
tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300", tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
}, },
{
label: "Purchasing Queue",
value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access",
detail:
snapshot?.purchaseOrders !== null
? `${issuedPurchaseOrderCount} issued/approved and ${formatCurrency(purchaseOrderValue)} committed`
: "Purchasing metrics are permission-gated.",
tone: "border-teal-400/30 bg-teal-500/12 text-teal-700 dark:text-teal-300",
},
{ {
label: "Manufacturing Load", label: "Manufacturing Load",
value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access", value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access",
@@ -293,6 +314,22 @@ export function DashboardPage() {
{ label: "Open warehouses", to: "/inventory/warehouses" }, { label: "Open warehouses", to: "/inventory/warehouses" },
], ],
}, },
{
title: "Purchasing",
eyebrow: "Inbound Supply",
summary:
snapshot?.purchaseOrders !== null
? "Purchase orders, open commitments, and current inbound procurement load are now visible from the dashboard."
: "Purchasing read permission is required to surface procurement metrics here.",
metrics: [
{ label: "Open POs", value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access" },
{ label: "Issued", value: snapshot?.purchaseOrders !== null ? `${issuedPurchaseOrderCount}` : "No access" },
{ label: "Committed", value: snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access" },
],
links: [
{ label: "Open purchase orders", to: "/purchasing/orders" },
],
},
{ {
title: "Manufacturing", title: "Manufacturing",
eyebrow: "Execution Load", eyebrow: "Execution Load",
@@ -364,9 +401,9 @@ export function DashboardPage() {
]; ];
const futureModules = [ const futureModules = [
"Vendor invoice attachments and supplier exception queues",
"Stock transfers, allocations, and cycle counts", "Stock transfers, allocations, and cycle counts",
"Planning timeline, milestones, and dependency views", "Planning timeline, milestones, and dependency views",
"Sales approvals, revisions, and change history",
"Audit trails, diagnostics, and system health checks", "Audit trails, diagnostics, and system health checks",
]; ];
@@ -388,8 +425,8 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted"> <p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
This landing page now reads directly from live CRM, inventory, manufacturing, sales, shipping, and project data. It is intentionally This landing page now reads directly from live CRM, inventory, purchasing, manufacturing, sales, shipping, and project data. It is
modular so future purchasing, planning, and audit slices can slot into the same command surface without a redesign. intentionally modular so future planning, approvals, and audit slices can slot into the same command surface without a redesign.
</p> </p>
<div className="mt-5 grid gap-2 sm:grid-cols-3"> <div className="mt-5 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2"> <div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
@@ -415,6 +452,9 @@ export function DashboardPage() {
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items"> <Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items">
Open inventory Open inventory
</Link> </Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/purchasing/orders">
Open purchasing
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects"> <Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
Open projects Open projects
</Link> </Link>
@@ -437,7 +477,7 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</section> </section>
<section className="grid gap-3 xl:grid-cols-6"> <section className="grid gap-3 xl:grid-cols-7">
{metricCards.map((card) => ( {metricCards.map((card) => (
<article key={card.label} className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article key={card.label} 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">{card.label}</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{card.label}</p>
@@ -449,7 +489,7 @@ export function DashboardPage() {
</article> </article>
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-6"> <section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-7">
{modulePanels.map((panel) => ( {modulePanels.map((panel) => (
<article key={panel.title} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article key={panel.title} 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">{panel.eyebrow}</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{panel.eyebrow}</p>
@@ -473,7 +513,7 @@ export function DashboardPage() {
</article> </article>
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-5"> <section className="grid gap-3 xl:grid-cols-6">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <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">Inventory Watch</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4> <h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>
@@ -510,6 +550,24 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</article> </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">Purchasing Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Inbound supply and commitment load</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Total purchase orders</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${purchaseOrderCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Open queue</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Committed value</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access"}</span>
</div>
</div>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <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">Manufacturing Watch</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</h4> <h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</h4>

View File

@@ -5,7 +5,7 @@ import type {
} from "@mrp/shared"; } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js"; import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
@@ -15,6 +15,8 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth(); const { token } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { workOrderId } = useParams(); const { workOrderId } = useParams();
const [searchParams] = useSearchParams();
const seededProjectId = searchParams.get("projectId");
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput); const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]); const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]); const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
@@ -32,9 +34,18 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
} }
api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([])); api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
api.getManufacturingProjectOptions(token).then(setProjectOptions).catch(() => setProjectOptions([])); api.getManufacturingProjectOptions(token).then((options) => {
setProjectOptions(options);
if (mode === "create" && seededProjectId) {
const seededProject = options.find((option) => option.id === seededProjectId);
if (seededProject) {
setForm((current) => ({ ...current, projectId: seededProject.id }));
setProjectSearchTerm(`${seededProject.projectNumber} - ${seededProject.name}`);
}
}
}).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([])); api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [token]); }, [mode, seededProjectId, token]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) { if (!token || mode !== "edit" || !workOrderId) {

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js"; import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
import type { WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -13,6 +14,7 @@ export function ProjectDetailPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const { projectId } = useParams(); const { projectId } = useParams();
const [project, setProject] = useState<ProjectDetailDto | null>(null); const [project, setProject] = useState<ProjectDetailDto | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [status, setStatus] = useState("Loading project..."); const [status, setStatus] = useState("Loading project...");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false; const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
@@ -26,7 +28,9 @@ export function ProjectDetailPage() {
.then((nextProject) => { .then((nextProject) => {
setProject(nextProject); setProject(nextProject);
setStatus("Project loaded."); setStatus("Project loaded.");
return api.getWorkOrders(token, { projectId: nextProject.id });
}) })
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
.catch((error: unknown) => { .catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load project."; const message = error instanceof ApiError ? error.message : "Unable to load project.";
setStatus(message); setStatus(message);
@@ -93,6 +97,36 @@ export function ProjectDetailPage() {
</div> </div>
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Links</p>
<p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p>
</div>
{canManage ? (
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New work order
</Link>
) : null}
</div>
{workOrders.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 work orders are linked to this project yet.</div>
) : (
<div className="mt-6 space-y-3">
{workOrders.map((workOrder) => (
<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{workOrder.workOrderNumber}</div>
<div className="mt-1 text-xs text-muted">{workOrder.itemSku} · {workOrder.completedQuantity}/{workOrder.quantity} complete</div>
</div>
<div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div>
</div>
</Link>
))}
</div>
)}
</section>
<FileAttachmentsPanel <FileAttachmentsPanel
ownerType="PROJECT" ownerType="PROJECT"
ownerId={project.id} ownerId={project.id}

View File

@@ -22,6 +22,16 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const [quoteOptions, setQuoteOptions] = useState<ProjectDocumentOptionDto[]>([]); const [quoteOptions, setQuoteOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [orderOptions, setOrderOptions] = useState<ProjectDocumentOptionDto[]>([]); const [orderOptions, setOrderOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [shipmentOptions, setShipmentOptions] = useState<ProjectShipmentOptionDto[]>([]); const [shipmentOptions, setShipmentOptions] = useState<ProjectShipmentOptionDto[]>([]);
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
const [ownerSearchTerm, setOwnerSearchTerm] = useState("");
const [quoteSearchTerm, setQuoteSearchTerm] = useState("");
const [orderSearchTerm, setOrderSearchTerm] = useState("");
const [shipmentSearchTerm, setShipmentSearchTerm] = useState("");
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
const [ownerPickerOpen, setOwnerPickerOpen] = useState(false);
const [quotePickerOpen, setQuotePickerOpen] = useState(false);
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project..."); const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -66,6 +76,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
dueDate: project.dueDate, dueDate: project.dueDate,
notes: project.notes, notes: project.notes,
}); });
setCustomerSearchTerm(project.customerName);
setOwnerSearchTerm(project.ownerName ?? "");
setQuoteSearchTerm(project.salesQuoteNumber ?? "");
setOrderSearchTerm(project.salesOrderNumber ?? "");
setShipmentSearchTerm(project.shipmentNumber ?? "");
setStatus("Project loaded."); setStatus("Project loaded.");
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -88,6 +103,20 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
})); }));
} }
function restoreSearchTerms() {
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
const selectedQuote = quoteOptions.find((quote) => quote.id === form.salesQuoteId);
const selectedOrder = orderOptions.find((order) => order.id === form.salesOrderId);
const selectedShipment = shipmentOptions.find((shipment) => shipment.id === form.shipmentId);
setCustomerSearchTerm(selectedCustomer?.name ?? "");
setOwnerSearchTerm(selectedOwner?.fullName ?? "");
setQuoteSearchTerm(selectedQuote?.documentNumber ?? "");
setOrderSearchTerm(selectedOrder?.documentNumber ?? "");
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -128,10 +157,47 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span> <span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<select value={form.customerId} onChange={(event) => updateField("customerId", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"> <div className="relative">
<option value="">Select customer</option> <input
{customerOptions.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)} value={customerSearchTerm}
</select> onChange={(event) => {
setCustomerSearchTerm(event.target.value);
updateField("customerId", "");
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setCustomerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search customer"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{customerPickerOpen ? (
<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">
{customerOptions
.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((customer) => (
<button key={customer.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("customerId", customer.id);
setCustomerSearchTerm(customer.name);
setCustomerPickerOpen(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">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
</button>
))}
</div>
) : null}
</div>
</label> </label>
</div> </div>
<div className="grid gap-3 xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-4">
@@ -149,10 +215,55 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Owner</span> <span className="mb-2 block text-sm font-semibold text-text">Owner</span>
<select value={form.ownerId ?? ""} onChange={(event) => updateField("ownerId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"> <div className="relative">
<option value="">Unassigned</option> <input
{ownerOptions.map((owner) => <option key={owner.id} value={owner.id}>{owner.fullName}</option>)} value={ownerSearchTerm}
</select> onChange={(event) => {
setOwnerSearchTerm(event.target.value);
updateField("ownerId", null);
setOwnerPickerOpen(true);
}}
onFocus={() => setOwnerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOwnerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search owner"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{ownerPickerOpen ? (
<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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", null);
setOwnerSearchTerm("");
setOwnerPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">Unassigned</div>
</button>
{ownerOptions
.filter((owner) => {
const query = ownerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return owner.fullName.toLowerCase().includes(query) || owner.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((owner) => (
<button key={owner.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", owner.id);
setOwnerSearchTerm(owner.fullName);
setOwnerPickerOpen(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">{owner.fullName}</div>
<div className="mt-1 text-xs text-muted">{owner.email}</div>
</button>
))}
</div>
) : null}
</div>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Due date</span> <span className="mb-2 block text-sm font-semibold text-text">Due date</span>
@@ -162,24 +273,159 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="grid gap-3 xl:grid-cols-3"> <div className="grid gap-3 xl:grid-cols-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quote</span> <span className="mb-2 block text-sm font-semibold text-text">Quote</span>
<select value={form.salesQuoteId ?? ""} onChange={(event) => updateField("salesQuoteId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"> <div className="relative">
<option value="">No linked quote</option> <input
{quoteOptions.map((quote) => <option key={quote.id} value={quote.id}>{quote.documentNumber}</option>)} value={quoteSearchTerm}
</select> onChange={(event) => {
setQuoteSearchTerm(event.target.value);
updateField("salesQuoteId", null);
setQuotePickerOpen(true);
}}
onFocus={() => setQuotePickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setQuotePickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search quote"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{quotePickerOpen ? (
<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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked quote</div>
</button>
{quoteOptions
.filter((quote) => {
const query = quoteSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return quote.documentNumber.toLowerCase().includes(query) || quote.customerName.toLowerCase().includes(query) || quote.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((quote) => (
<button key={quote.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", quote.id);
setQuoteSearchTerm(quote.documentNumber);
setQuotePickerOpen(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">{quote.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{quote.customerName} · {quote.status}</div>
</button>
))}
</div>
) : null}
</div>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span> <span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
<select value={form.salesOrderId ?? ""} onChange={(event) => updateField("salesOrderId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"> <div className="relative">
<option value="">No linked sales order</option> <input
{orderOptions.map((order) => <option key={order.id} value={order.id}>{order.documentNumber}</option>)} value={orderSearchTerm}
</select> onChange={(event) => {
setOrderSearchTerm(event.target.value);
updateField("salesOrderId", null);
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOrderPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search sales order"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{orderPickerOpen ? (
<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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked sales order</div>
</button>
{orderOptions
.filter((order) => {
const query = orderSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return order.documentNumber.toLowerCase().includes(query) || order.customerName.toLowerCase().includes(query) || order.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((order) => (
<button key={order.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", order.id);
setOrderSearchTerm(order.documentNumber);
setOrderPickerOpen(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">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{order.customerName} · {order.status}</div>
</button>
))}
</div>
) : null}
</div>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span> <span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
<select value={form.shipmentId ?? ""} onChange={(event) => updateField("shipmentId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"> <div className="relative">
<option value="">No linked shipment</option> <input
{shipmentOptions.map((shipment) => <option key={shipment.id} value={shipment.id}>{shipment.shipmentNumber}</option>)} value={shipmentSearchTerm}
</select> onChange={(event) => {
setShipmentSearchTerm(event.target.value);
updateField("shipmentId", null);
setShipmentPickerOpen(true);
}}
onFocus={() => setShipmentPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setShipmentPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search shipment"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{shipmentPickerOpen ? (
<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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked shipment</div>
</button>
{shipmentOptions
.filter((shipment) => {
const query = shipmentSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return shipment.shipmentNumber.toLowerCase().includes(query) || shipment.salesOrderNumber.toLowerCase().includes(query) || shipment.customerName.toLowerCase().includes(query) || shipment.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((shipment) => (
<button key={shipment.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", shipment.id);
setShipmentSearchTerm(shipment.shipmentNumber);
setShipmentPickerOpen(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">{shipment.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{shipment.salesOrderNumber} · {shipment.status}</div>
</button>
))}
</div>
) : null}
</div>
</label> </label>
</div> </div>
<label className="block"> <label className="block">

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config"; import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
import { PurchaseStatusBadge } from "./PurchaseStatusBadge"; import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
@@ -248,7 +249,7 @@ export function PurchaseDetailPage() {
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <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> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor</p>
<dl className="mt-5 grid gap-3"> <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">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/vendors/${activeDocument.vendorId}`} className="hover:text-brand">{activeDocument.vendorName}</Link></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> <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> </dl>
</article> </article>
@@ -422,6 +423,14 @@ export function PurchaseDetailPage() {
)} )}
</article> </article>
</section> </section>
<FileAttachmentsPanel
ownerType="PURCHASE_ORDER"
ownerId={activeDocument.id}
eyebrow="Supporting Documents"
title="Vendor invoices and backup"
description="Store vendor invoices, acknowledgements, certifications, and supporting procurement documents directly on the purchase order."
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div> <div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section> </section>
); );

View File

@@ -1,6 +1,6 @@
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared"; import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
@@ -11,6 +11,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth(); const { token } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { orderId } = useParams(); const { orderId } = useParams();
const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput); const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order..."); const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]); const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
@@ -30,9 +32,18 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
return; return;
} }
api.getPurchaseVendors(token).then(setVendors).catch(() => setVendors([])); api.getPurchaseVendors(token).then((nextVendors) => {
setVendors(nextVendors);
if (mode === "create" && seededVendorId) {
const seededVendor = nextVendors.find((vendor) => vendor.id === seededVendorId);
if (seededVendor) {
setForm((current: PurchaseOrderInput) => ({ ...current, vendorId: seededVendor.id }));
setVendorSearchTerm(seededVendor.name);
}
}
}).catch(() => setVendors([]));
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([])); api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
}, [token]); }, [mode, seededVendorId, token]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "edit" || !orderId) { if (!token || mode !== "edit" || !orderId) {

View File

@@ -37,6 +37,7 @@ const purchaseOrderSchema = z.object({
const purchaseListQuerySchema = z.object({ const purchaseListQuerySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
status: z.enum(purchaseOrderStatuses).optional(), status: z.enum(purchaseOrderStatuses).optional(),
vendorId: z.string().optional(),
}); });
const purchaseStatusUpdateSchema = z.object({ const purchaseStatusUpdateSchema = z.object({

View File

@@ -480,11 +480,12 @@ export async function listPurchaseVendorOptions(): Promise<PurchaseVendorOptionD
return vendors; return vendors;
} }
export async function listPurchaseOrders(filters: { q?: string; status?: PurchaseOrderStatus } = {}) { export async function listPurchaseOrders(filters: { q?: string; status?: PurchaseOrderStatus; vendorId?: string } = {}) {
const query = filters.q?.trim(); const query = filters.q?.trim();
const records = await purchaseOrderModel.findMany({ const records = await purchaseOrderModel.findMany({
where: { where: {
...(filters.status ? { status: filters.status } : {}), ...(filters.status ? { status: filters.status } : {}),
...(filters.vendorId ? { vendorId: filters.vendorId } : {}),
...(query ...(query
? { ? {
OR: [ OR: [