Compare commits

...

63 Commits

Author SHA1 Message Date
e65ed892f1 shipping label fix - codex 2026-03-19 21:47:26 -05:00
jason
ce2d52db53 hopeful 2026-03-19 16:38:41 -05:00
jason
39fd876d51 fixing 2026-03-19 16:34:18 -05:00
jason
0c3b2cf6fe fixes 2026-03-19 16:19:48 -05:00
jason
6423dfb91b again 2026-03-19 16:14:35 -05:00
jason
26b188de87 fix 2026-03-19 16:12:10 -05:00
jason
0b43b4ebf5 shipping fix 2026-03-19 16:06:50 -05:00
jason
3c312733ca shipping label 2 2026-03-19 16:01:32 -05:00
jason
9d54dc2ecd shipping label 2026-03-19 15:57:39 -05:00
jason
b762c70238 clean up usage guide 2026-03-19 15:37:51 -05:00
jason
9562c1cc9c usage guide 2026-03-19 13:09:29 -05:00
3eba7c5fa6 workbench 2026-03-19 07:41:06 -05:00
4949b6033f more workbench usability 2026-03-19 07:38:08 -05:00
cf54e4ba58 usability workbench 2026-03-18 23:48:14 -05:00
061057339b more 2026-03-18 23:42:30 -05:00
7b65fe06cf more workbench 2026-03-18 23:32:12 -05:00
d22e715f00 workbench 2026-03-18 23:28:27 -05:00
5fdd366bc3 last cleanup 2026-03-18 23:22:11 -05:00
afad00bf46 1 2026-03-18 23:17:44 -05:00
28ea1ee6b9 cleanup 2026-03-18 23:14:47 -05:00
00a4da346f cleanup 2026-03-18 23:10:28 -05:00
52bc98c16e cleanup 2026-03-18 23:06:44 -05:00
17b73a4597 cleanup 2026-03-18 22:51:17 -05:00
dc07bfc8e0 cleanup 2026-03-18 22:44:01 -05:00
1e408d5316 density 2026-03-18 20:36:30 -05:00
69dfec98ad fixes 2026-03-18 12:05:28 -05:00
f12744f05d backfill from projects 2026-03-18 11:54:22 -05:00
c18de77640 ROADMAP 2026-03-18 11:41:37 -05:00
f85563ce99 finance 2026-03-18 11:24:59 -05:00
02e14319ac pick orders 2026-03-18 07:27:33 -05:00
e00639bb8b timers 2026-03-18 06:39:38 -05:00
c49ed4bf4a manufacturing layer 2026-03-18 06:22:37 -05:00
6eaf084fcd drag scheduling 2026-03-18 00:18:30 -05:00
abc795b4a7 workbench rebalance 2026-03-18 00:10:15 -05:00
14708d7013 planning payload 2026-03-17 23:52:58 -05:00
66d8814d89 no gantt 2026-03-17 23:35:37 -05:00
b02b764b2f fabdash absorb 2026-03-17 21:12:27 -05:00
c06cb66893 projects 2026-03-17 21:04:33 -05:00
cdbd54b8cc cost rollups 2026-03-17 19:17:12 -05:00
f772ccacc7 project cockpit 2026-03-17 19:13:54 -05:00
7993f16a76 projects 2026-03-17 07:40:12 -05:00
c1f6386e7d projects milestones 2026-03-17 07:34:08 -05:00
c3f0adc676 MERKET 2026-03-17 00:24:33 -05:00
279c46fbde landing cleanup 2026-03-17 00:07:44 -05:00
0d7282664e landing pages 2026-03-16 23:59:31 -05:00
c6931d5c5d sku auto collapse 2026-03-16 23:34:24 -05:00
a1b5d7aa84 1 2026-03-16 00:03:24 -05:00
f60d534f64 rebrand CODEXIUM 2026-03-16 00:00:04 -05:00
daced2b7c9 fixed user update 2026-03-15 23:41:58 -05:00
89282896e8 PO fix 2026-03-15 23:24:04 -05:00
f0351cbed5 theme color persist 2026-03-15 23:12:53 -05:00
b0ea997b8b item thumbnails 2026-03-15 22:59:16 -05:00
26ee928869 item thumbnails 2026-03-15 22:51:35 -05:00
2718e8b4b1 sku builder first test 2026-03-15 22:17:58 -05:00
f2b820746a item master cleanup 2026-03-15 21:32:45 -05:00
8029b308e9 data and layout cleanup 2026-03-15 21:26:08 -05:00
ac0c6e4365 dashboard cleanup 2026-03-15 21:17:54 -05:00
a43374fe77 doc compare 2026-03-15 21:07:28 -05:00
f3e421e9e3 no more pills 2026-03-15 20:07:48 -05:00
e88d949a59 ui cleanup 2026-03-15 19:58:26 -05:00
dcac4f135d cleanup 2026-03-15 19:40:35 -05:00
275c73b584 cleanup 2026-03-15 19:22:20 -05:00
df041254da confirm actions 2026-03-15 18:59:37 -05:00
114 changed files with 15609 additions and 2448 deletions

Submodule .claude/worktrees/inspiring-leavitt deleted from 2cf6bf858d

View File

@@ -6,7 +6,7 @@ This file defines project-specific guidance for future contributors and coding a
## Project overview ## Project overview
MRP Codex is a modular Manufacturing Resource Planning platform intended to be a lighter, sleeker alternative to Odoo. The current repository contains the foundation release: CODEXIUM is a modular Manufacturing Resource Planning platform intended to be a lighter, sleeker alternative to Odoo. The current repository contains the foundation release:
- React + Vite + Tailwind frontend - React + Vite + Tailwind frontend
- Express + TypeScript backend - Express + TypeScript backend
@@ -17,7 +17,10 @@ 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
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation - inventory transfers, reservations, available-stock visibility, and work-order reservation automation
- sales quotes, sales orders, approvals, revision history, and purchase orders - inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
- inventory thumbnail image staging on create/edit and dedicated thumbnail display on item detail
- sales quotes, sales orders, approvals, revision history/comparison, and purchase orders
- purchase-order revision history and revision comparison across document and receipt changes
- purchase-order supporting documents and vendor-side purchasing visibility - 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
@@ -28,11 +31,13 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults - pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing - shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, and review filtering
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow - CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow - backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
- backup verification checklist and restore-drill runbook in the admin diagnostics workflow - backup verification checklist and restore-drill runbook in the admin diagnostics workflow
- support-log viewing and support debugging helpers in the admin diagnostics workflow - support-log viewing, filtering, retention cleanup, and support debugging helpers in the admin diagnostics workflow
- startup brand-theme hydration so Company Settings colors persist across refresh
- Puppeteer PDF foundation - Puppeteer PDF foundation
- single-container Docker deployment - single-container Docker deployment
@@ -45,6 +50,7 @@ Read these before major work:
- [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md) - [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md)
- [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md) - [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md)
- [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) - [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md)
- [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md)
- [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) - [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md)
If implementation changes invalidate those docs, update them in the same change set. Keep `CHANGELOG.md` current for shipped features, behavior changes, and notable operational updates. If implementation changes invalidate those docs, update them in the same change set. Keep `CHANGELOG.md` current for shipped features, behavior changes, and notable operational updates.
@@ -129,8 +135,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. Better user and session visibility for operational admins 1. Project milestones and project-side rollup visibility
2. Safer destructive-action confirmations and recovery messaging 2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
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.
@@ -159,3 +165,4 @@ If you cannot run one of those checks, say so explicitly.
- Prisma runtime on Debian bookworm requires `debian-openssl-3.0.x` - Prisma runtime on Debian bookworm requires `debian-openssl-3.0.x`
- `shared` package exports must use Node ESM-compatible `.js` specifiers - `shared` package exports must use Node ESM-compatible `.js` specifiers
- Local Docker validation may fail if the Docker daemon is unavailable; distinguish daemon issues from image issues - Local Docker validation may fail if the Docker daemon is unavailable; distinguish daemon issues from image issues

View File

@@ -1,11 +1,68 @@
# Changelog # Changelog
This file is the running release and change log for MRP Codex. Keep it updated whenever shipped functionality, architecture expectations, deployment behavior, or operator-facing workflows materially change. This file is the running release and change log for CODEXIUM. Keep it updated whenever shipped functionality, architecture expectations, deployment behavior, or operator-facing workflows materially change.
## Unreleased ## Unreleased
### Added ### Added
- Reverse project visibility on quote and sales-order detail pages, purchase-order header project linkage, sales-order-driven project auto-derivation for new work orders and purchase orders, quote-to-sales-order project carry-through during conversion, and migration backfill for existing work orders and purchase orders linked through project sales orders
- Finance module with customer-payment posting against sales orders, finance costing assumptions, sales-order cash/spend ledger rollups, manufacturing cost snapshots, and CapEx tracking for equipment, tooling, and consumables
- Inventory-backed shipment picking from shipment detail pages, including sales-order line remaining-quantity visibility, warehouse/location source selection, issued-stock posting, and shipment pick history
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline
- Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer
- Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions
- Manufacturing finite-capacity slice with station daily capacity, parallel capacity, working-day calendars, calendar-aware operation scheduling, and operation-level rescheduling from the work-order detail page
- Manufacturing station edit support for working days, active state, queue, and capacity settings directly from the manufacturing screen
- Operation execution controls on work orders, including start/pause/resume/complete actions, labor posting, and actual-minute rollups by operation and work order
- Operation operator assignment and timer-based labor capture, with timer stop posting elapsed minutes back as labor entries
- Workbench rebalance controls for operation rows, including planner-side datetime rescheduling, quick shift moves, and heatmap-day targeting without leaving the dispatch surface
- Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface
- Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed
- Workbench station cards now show planned-vs-actual load so planners can compare schedule intent against recorded execution time
- Work-order `On Hold` quick status changes now require a recorded hold reason and persist the active blocker on the work-order record and audit trail
- Project milestone cards now support inline quick status actions for start, block, complete, reset, and reopen flows directly from the project detail view
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
- UI density standardization pass across app shell, dashboard, finance, project detail, manufacturing detail, and admin surfaces, including tighter panel spacing, more compact shell/navigation spacing, and removal of redundant explanatory subcopy in favor of concise uppercase section labels
- Continued density standardization across CRM, inventory, sales, purchasing, and shipping list/detail surfaces so module headers, filter bars, and status panels follow the same tighter uppercase operational pattern
- Continued density standardization across CRM, sales, purchasing, shipping, manufacturing, and project form/detail headers so editor and record surfaces now follow the same compact uppercase pattern with less redundant helper copy
- Continued density standardization across CRM detail internals and inventory item editing so secondary cards, timeline/history panels, thumbnail panels, BOM/routing editors, and empty states use the tighter shared surface treatment with less filler copy
- Continued density standardization across inventory detail transaction/transfer/reservation surfaces, and fixed item-editor navigation controls so SKU master and cancel actions navigate reliably from the create-item form
- Continued density standardization across sales, purchasing, shipping, and manufacturing editor internals, and standardized form-header cancel actions onto button-driven navigation to avoid in-form route-transition edge cases
- Continued density standardization across sales, purchasing, shipping, and manufacturing detail internals, including denser KPI strips, tighter side panels, shorter empty states, and less redundant context copy on high-traffic record views
- Continued density standardization across shared attachment and revision-comparison surfaces, and changed inventory item-editor exit actions to hard navigation so SKU master and cancel transitions no longer depend on client-side router state
- Continued density standardization across the SKU master builder and planning workbench, including tighter tree and board panels, denser exception and focus surfaces, shorter empty states, and less helper copy on those operational screens
- Continued density standardization across warehouse list/detail/editor screens and the manufacturing station surface, including tighter status blocks, denser location/station cards, and removal of older roomy header patterns
- Continued density standardization across company settings and deeper manufacturing detail surfaces, including tighter admin/profile/theme sections, denser work-order execution panels, and compact issue/completion history cards
- Continued density standardization across project cockpit/detail internals, including tighter cockpit cards, denser purchasing and readiness panels, and compact milestone, manufacturing-link, and activity-timeline surfaces
- Continued density standardization across admin diagnostics, user management, and CRM contacts, including tighter filter/forms, denser summary cards, and compact contact/account management surfaces
- Workbench usability pass with sticky planner controls, stronger selected-row and selected-day state, clearer heatmap/day context, and more explicit dispatch-oriented action affordances
- Workbench usability depth with keyboard row navigation, enter-to-open behavior, escape-to-clear, and inline readiness/shortage/hold signal pills across planner rows and day-detail cards
- Workbench dispatch workflow depth with saved planner views, a release queue for visible ready work, queued-record visibility in the sticky control bar, and batch release directly from the workbench
- Workbench batch operation rebalance with multi-operation selection, sticky-bar batch reschedule controls, station reassignment across selected operations, and selected-operation visibility in row signals and focus context
- Workbench conflict-intelligence pass with projected batch target load, overload warnings before batch station moves, and best-alternate-station suggestions inside the sticky rebalance controls
- Workbench date-aware slot guidance using station working-day calendars and queue settings to suggest the next workable batch landing dates directly from the sticky rebalance controls
- Planning timeline now includes station day-load rollups, and Workbench slot suggestions use that server-backed per-day capacity data instead of only summary-level utilization heuristics
- Workbench now surfaces day-level capacity directly in the planner, including hot-station day counts on heatmap cells, selected-day station load breakdowns, and per-station hot-day chips in station grouping mode
- Workbench exception prioritization now scores and ranks projects, work orders, agenda rows, and dispatch exceptions by lateness, blockage, shortage, readiness, and overload pressure, with inline priority chips for faster triage
- Workbench now surfaces top-priority action lanes for `DO NOW`, `UNBLOCK`, and `RELEASE READY` records so planners can jump straight into ranked dispatch queues before working deeper lists
- Workbench action lanes now support direct follow-through from the lane cards themselves, including queue-release and the first inline build/buy/open actions without requiring a second step into the focus drawer
- Project-side milestone and work-order rollups surfaced on project list and detail pages
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
- Dedicated inventory-item thumbnail display on the item detail page
- Revision comparison views for sales quotes, sales orders, and purchase orders with field- and line-level before/after diffs
- Purchase-order revision snapshots covering document edits, status changes, and receipt posting
- Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings
- Session filters and text search for admin-side access review across user, email, IP, user agent, and review reasons
- Support-log filtering by severity, source, search text, and retention window in admin diagnostics
- Support-log export and support-snapshot export now carry filter context, summary counts, available sources, and retention metadata
- Shared destructive-action confirmation dialog with impact and recovery guidance for high-risk operational actions
- Typed confirmation for sensitive admin actions such as account deactivation, current-session revocation, and terminal manufacturing/inventory postings
- Destructive-action confirmation and recovery coverage for sales approvals, quote conversion, purchase receiving, purchase status changes, and shipment status changes
- Destructive-action confirmation coverage for project customer/document unlinking and embedded form-row removals in sales, purchasing, inventory, and warehouse editors
- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins
- Admin-side session revocation controls plus server-side logout that invalidates the current JWT-backed session
- Shared shortage and readiness rollups across dashboard, planning, project detail, purchasing detail, and manufacturing detail - Shared shortage and readiness rollups across dashboard, planning, project detail, purchasing detail, and manufacturing detail
- Prefilled work-order draft launch for build recommendations and prefilled purchase-order draft launch for buy recommendations from sales-order demand planning - Prefilled work-order draft launch for build recommendations and prefilled purchase-order draft launch for buy recommendations from sales-order demand planning
- Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children - Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children
@@ -29,8 +86,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Manual inventory reservations plus automatic work-order-driven component reservations - Manual inventory reservations plus automatic work-order-driven component reservations
- Reserved and available stock visibility on inventory item detail and stock-by-location views - Reserved and available stock visibility on inventory item detail and stock-by-location views
- Manufacturing stations with queue-day definitions and item-level station/time operation templates - Manufacturing stations with queue-day definitions and item-level station/time operation templates
- Automatic work-order operation plans copied from buildable item routing into planning/gantt - Automatic work-order operation plans copied from buildable item routing into the planning workbench
- Live planning gantt timelines backed by active projects and open manufacturing work orders - Live planning workbench timelines backed by active projects and open manufacturing work orders
- Planning summary metrics and exception cards for overdue or at-risk project/manufacturing schedule items - Planning summary metrics and exception cards for overdue or at-risk project/manufacturing schedule items
- Sales approval actions with approved-by/approved-at stamps on quotes and sales orders - Sales approval actions with approved-by/approved-at stamps on quotes and sales orders
- Automatic sales-document revision history with authored reasons and per-revision snapshots - Automatic sales-document revision history with authored reasons and per-revision snapshots
@@ -46,9 +103,22 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Changed ### Changed
- Shipping-label PDFs now render inside an explicit single-page 4x6 canvas with tighter print-safe spacing and overflow-safe text wrapping to prevent second-sheet runover on label printers
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
- Admin user edits now normalize blank passwords to null and surface save failures instead of appearing unresponsive
- Fresh bootstrap now creates only minimal system records; operational domains no longer start with seeded demo customers, vendors, inventory, BOMs, or warehouses
- Sales and purchasing detail pages now expose revision comparison directly alongside chronological revision history
- `ROADMAP.md` now tracks remaining work only, and shipped phase history now lives in `SHIPPED.md`
- Support logs now prune retained entries by age instead of only trimming by count, and admin diagnostics now reviews filtered support-log summaries instead of an unbounded flat dump
- Admin diagnostics now summarizes sessions that need review, and startup now prunes old expired or revoked auth-session records
- Admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse, and attachment workflows now use explicit destructive-action confirmation and recovery messaging instead of immediate irreversible clicks
- Admin operations now combine user management with live session visibility so operators can inspect and revoke sign-ins without changing user records
- JWT authentication now validates against persisted session records and inactive users lose access immediately instead of waiting for token expiry
- 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
- The dashboard now treats Planning as a live first-class module with direct gantt access from the landing page - The dashboard now treats Planning as a live first-class module with direct workbench access from the landing page
- Inventory control now distinguishes on-hand, reserved, and available stock instead of treating all positive stock as fully free - Inventory control now distinguishes on-hand, reserved, and available stock instead of treating all positive stock as fully free
- Manufacturing and inventory now share a routing-driven workflow where assemblies/manufactured parts define station/time templates and work orders inherit them automatically - Manufacturing and inventory now share a routing-driven workflow where assemblies/manufactured parts define station/time templates and work orders inherit them automatically
- Sales quote and sales-order detail pages now surface approval state and revision history directly in the operational workflow - Sales quote and sales-order detail pages now surface approval state and revision history directly in the operational workflow
@@ -122,3 +192,4 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Vendor invoice/supporting-document attachments - Vendor invoice/supporting-document attachments
- Sales approvals and document revision history - Sales approvals and document revision history
- Projects, manufacturing execution, and planning depth - Projects, manufacturing execution, and planning depth

View File

@@ -3,7 +3,7 @@
## Documentation maintenance ## Documentation maintenance
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated whenever shipped functionality, architecture expectations, deployment behavior, or user-facing workflows materially change. - Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated whenever shipped functionality, architecture expectations, deployment behavior, or user-facing workflows materially change.
- If a change invalidates [README.md](D:/CODING/mrp-codex/README.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), or [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), update those files in the same change set. - If a change invalidates [README.md](D:/CODING/mrp-codex/README.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md), or [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), update those files in the same change set.
## Current milestone ## Current milestone
@@ -16,11 +16,14 @@ This repository implements the platform foundation milestone:
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata - CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments - inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation - inventory transfers, reservations, available-stock visibility, and work-order reservation automation
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
- inventory thumbnail image staging on create/edit and dedicated thumbnail display on item detail
- sales quotes and sales orders with quick actions and quote conversion - sales quotes and sales orders with quick actions and quote conversion
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders - sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
- purchase orders with quick actions and searchable vendor/SKU entry - purchase orders with quick actions and searchable vendor/SKU entry
- 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
- purchase-order revision history and revision comparison across document and receipt changes
- 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 - 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
@@ -32,11 +35,13 @@ This repository implements the platform foundation milestone:
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults - pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing - shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity - admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, and review filtering
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
- CRM/shipping audit coverage and startup validation surfaced through diagnostics - CRM/shipping audit coverage and startup validation surfaced through diagnostics
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics - backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
- backup verification checklist and restore-drill runbook in diagnostics - backup verification checklist and restore-drill runbook in diagnostics
- support-log viewing and support debugging helpers in diagnostics - support-log viewing, filtering, retention cleanup, and support debugging helpers in diagnostics
- startup brand-theme hydration so Company Settings colors persist correctly across refresh
- Dockerized single-container deployment - Dockerized single-container deployment
- Puppeteer PDF pipeline foundation - Puppeteer PDF pipeline foundation
@@ -50,11 +55,13 @@ This repository implements the platform foundation milestone:
6. Any non-filter UI that looks up records or items must use a searchable picker/autocomplete, not a long static dropdown. 6. Any non-filter UI that looks up records or items must use a searchable picker/autocomplete, not a long static dropdown.
7. Inventory items must carry both `defaultCost` and `defaultPrice`; sales documents should default line pricing from the selected item `defaultPrice`. 7. Inventory items must carry both `defaultCost` and `defaultPrice`; sales documents should default line pricing from the selected item `defaultPrice`.
8. Maintain the denser UI baseline on active screens; avoid reintroducing oversized `px-4 py-3` style controls, tall action bars, or overly loose card spacing without a specific reason. 8. Maintain the denser UI baseline on active screens; avoid reintroducing oversized `px-4 py-3` style controls, tall action bars, or overly loose card spacing without a specific reason.
9. Treat the landing page as `Dashboard`: a metric-oriented, modular command surface that should accumulate reusable operational panels over time. 9. Prefer concise uppercase module and section labels in the live interface, and avoid redundant descriptive subcopy when the surrounding data already makes the purpose clear.
10. Purchase-order item selection must be restricted to inventory items where `isPurchasable = true`. 10. When designing operational pages, bias toward information density: tighter panel padding, smaller stack gaps, and fewer explanatory filler blocks.
11. Treat `Projects` as a first-class cross-module domain tying together CRM, sales, inventory, purchasing, shipping, and planning; do not bury it as a one-off manufacturing subfeature. 11. Treat the landing page as `Dashboard`: a metric-oriented, modular command surface that should accumulate reusable operational panels over time.
12. Keep `Projects`, `Manufacturing`, and `Planning` distinct: projects are long-running program records, manufacturing is execution, and planning is scheduling/visibility. 12. Purchase-order item selection must be restricted to inventory items where `isPurchasable = true`.
13. New top-level modules added to the app shell should include a matching SVG icon in navigation so the module list remains visually scannable. 13. Treat `Projects` as a first-class cross-module domain tying together CRM, sales, inventory, purchasing, shipping, and planning; do not bury it as a one-off manufacturing subfeature.
14. Keep `Projects`, `Manufacturing`, and `Planning` distinct: projects are long-running program records, manufacturing is execution, and planning is scheduling/visibility.
15. New top-level modules added to the app shell should include a matching SVG icon in navigation so the module list remains visually scannable.
## Operational notes ## Operational notes
@@ -72,5 +79,6 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- better user and session visibility for operational admins - project milestones and project-side rollup visibility
- safer destructive-action confirmations and recovery messaging - manufacturing routing/work-center depth, labor capture, and capacity-aware execution views

526
MARKET.md Normal file
View File

@@ -0,0 +1,526 @@
# MARKET
## Purpose
This document compares CODEXIUM against the provided top-50 requested manufacturing MRP features and translates the gaps into a practical product roadmap.
Assessment basis:
- current shipped scope in [README.md](D:/CODING/mrp-codex/README.md)
- completed work in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md)
- planned work in [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md)
- current repository implementation state as of March 17, 2026
Status legend:
- `Implemented`: materially present in the product today
- `Partial`: meaningful foundation exists, but the market-expected version is not complete
- `Missing`: not present or not competitive enough to count as delivered
## Executive Summary
CODEXIUM is already strong for a foundation-stage MRP in these areas:
- integrated CRM, sales, purchasing, shipping, inventory, projects, manufacturing, and planning in one app
- multi-level BOM explosion for demand planning
- inventory reservations, transfers, and location-level stock visibility
- work orders, stations, operation templates, material issue/completion posting
- pegged build/buy recommendations tied back to sales demand
- project linkage across customer, commercial, shipment, and manufacturing records
- branded documents, attachments, audit trail, diagnostics, RBAC, and single-container deployment
CODEXIUM is not yet market-complete against the top-50 list. The current position is:
- `Implemented`: 3 / 50
- `Partial`: 13 / 50
- `Missing`: 34 / 50
That result is not a criticism of the product direction. It reflects that CODEXIUM is already a credible modular MRP foundation, but has not yet expanded into the deeper planning, traceability, execution, maintenance, quality, integration, and analytics layers expected by mature manufacturing buyers.
## Tier Summary
| Tier | Focus | Implemented | Partial | Missing | Readout |
| --- | --- | ---: | ---: | ---: | --- |
| Tier 1 | Core planning & scheduling | 0 | 3 | 7 | Good demand-planning base, but no full MPS/capacity/ATP engine yet |
| Tier 2 | Inventory & traceability | 3 | 0 | 7 | Strong stock visibility baseline, weak traceability/compliance depth |
| Tier 3 | Production execution & quality | 0 | 3 | 12 | Work-order foundation exists, but execution depth is still early |
| Tier 4 | Integration & interoperability | 0 | 3 | 7 | Internal modules are connected; external ecosystem integration is mostly absent |
| Tier 5 | Analytics, UX & platform quality | 0 | 4 | 1 | Strong UX intent and dashboard base, but not yet configurable/mobile/AI-driven |
## Comparative Analysis
### Tier 1: Core Planning & Scheduling
| Rank | Feature | Status | CODEXIUM Position |
| --- | --- | --- | --- |
| 1 | Purchase Planning & Automated PO Generation | `Partial` | Demand-planning recommendations and prefilled PO drafts exist, but not full autonomous purchasing from reorder points, lead times, min/max, and policy-driven replenishment. |
| 2 | Demand Forecasting from Historical Data | `Missing` | No historical demand forecasting engine or forecast-driven planning model is present. |
| 3 | Master Production Scheduling (MPS) | `Missing` | Planning is driven by live project/work-order/demand views, but there is no explicit top-level MPS layer. |
| 4 | Finite Capacity Scheduling | `Missing` | Current planning is not capacity-aware and does not enforce machine/labor constraints. |
| 5 | Multi-Level BOM Management | `Partial` | Multi-level BOM structure and explosion exist, but BOM versioning, ECO-driven effectivity, and parametric relationships are missing. |
| 6 | Sales Order to Production Job Auto-Conversion | `Partial` | Planner-assisted conversion to work-order drafts exists, but not true automatic conversion from confirmed sales demand into planned/released jobs. |
| 7 | Scenario Planning & Simulation | `Missing` | No what-if modeling or alternate-plan simulation is present. |
| 8 | Multi-Site / Multi-Plant Planning | `Missing` | Warehouses/locations exist, but not plant-aware planning, transfer planning, or site capacity coordination. |
| 9 | Subcontract / Outsourced Operation Management | `Missing` | No subcontracted operation flow, outside processing routing step, or supplier-linked manufacturing handoff exists. |
| 10 | Delivery Date Estimation (Available-to-Promise) | `Missing` | No ATP/CTP calculation is available for quoting or customer-service commitments. |
### Tier 2: Inventory & Traceability
| Rank | Feature | Status | CODEXIUM Position |
| --- | --- | --- | --- |
| 11 | Real-Time Inventory Visibility Across All Locations | `Implemented` | On-hand, reserved, available, warehouse/location balances, transfers, and transaction history are shipped. |
| 12 | Lot / Serial Number Tracking (Full Bi-Directional) | `Missing` | No lot genealogy, serial trace, or forward/backward traceability is implemented. |
| 13 | Inventory Reservation & Allocation | `Implemented` | Manual reservations and work-order-driven reservations are already present. |
| 14 | Barcode / RFID Scanning Support | `Missing` | No scanner-first receiving, picking, issue, completion, or count workflow exists. |
| 15 | Negative Stock Prevention Enforcement | `Missing` | No explicit stock-floor enforcement is documented or surfaced as a guarded planning/inventory rule. |
| 16 | FIFO / FEFO Inventory Consumption Rules | `Missing` | Inventory issue logic does not yet expose consumption policies by lot/date/age. |
| 17 | Recall Readiness & <2-Minute Trace Report | `Missing` | Without lot genealogy, recall impact reporting is not possible. |
| 18 | Multi-Location Inventory Management | `Implemented` | Warehouses and nested stock locations are already core to the inventory module. |
| 19 | Inventory Cycle Count & Physical Inventory Portals | `Missing` | No cycle-count workflow, count scheduling, or limited-access count portal exists. |
| 20 | Consignment & Vendor-Managed Inventory (VMI) | `Missing` | No supplier-owned stock model or consumption-based supplier settlement is present. |
### Tier 3: Production Execution & Quality
| Rank | Feature | Status | CODEXIUM Position |
| --- | --- | --- | --- |
| 21 | Digital Work Orders with Shop Floor Dispatch | `Partial` | Digital work orders exist, but dispatch, operator queueing, and workstation-first execution are still limited. |
| 22 | Real-Time Job Costing (Material + Labor + Overhead) | `Missing` | Material posting exists, but labor, overhead, and real-time job-cost accumulation do not. |
| 23 | Time & Labor Tracking by Job / Operation | `Missing` | No labor clock-in/out or operation time capture exists. |
| 24 | Work Center Capacity & Load Planning | `Partial` | Station master data and planning views exist, but no true work-center load balancing or capacity board is delivered. |
| 25 | Production Cost Reporting (Estimate vs. Actual) | `Missing` | No estimate-vs-actual job costing or variance analysis is currently present. |
| 26 | Paperless Shop Floor with Digital Work Instructions | `Partial` | Attachments exist on items and work orders, but not a dedicated operator instruction experience with current-revision control. |
| 27 | Quality Inspection Integration (Inline & Final) | `Missing` | No inspection plans, in-process checks, or work-order quality gate flow exists. |
| 28 | Non-Conformance (NCR) Tracking & CAPA Workflow | `Missing` | No NCR/CAPA subsystem exists. |
| 29 | Preventive Maintenance Scheduling (EAM) | `Missing` | No asset maintenance module or PM scheduler exists. |
| 30 | Machine & Equipment Integration (MES connectivity) | `Missing` | No machine data collection, cycle reporting, or automated shop-floor feedback loop exists. |
| 31 | Configure-Price-Quote (CPQ) for Engineer-to-Order | `Missing` | No product configurator, rules engine, or auto-BOM generation for configured products exists. |
| 32 | Subcontractor Material Free-Issue Tracking | `Missing` | No supplier-side issued-material reconciliation exists for outsourced operations. |
| 33 | Scrap & Yield Management | `Missing` | No expected vs actual yield, scrap capture, or material-adjusted production reporting exists. |
| 34 | Engineering Change Order (ECO) Management | `Missing` | Roadmap references deeper revision needs, but no ECO workflow is implemented. |
| 35 | Tooling & Fixture Tracking | `Missing` | No tooling availability, maintenance, or routing dependency model exists. |
### Tier 4: Integration & Interoperability
| Rank | Feature | Status | CODEXIUM Position |
| --- | --- | --- | --- |
| 36 | Native ERP / Accounting Integration (Bidirectional) | `Missing` | No bidirectional accounting or ERP sync layer exists. |
| 37 | CRM / Sales Order Integration | `Partial` | CRM and sales are natively integrated inside CODEXIUM, but not synced bidirectionally to external CRM systems. |
| 38 | EDI Support (ASC X12 / EDIFACT) | `Missing` | No EDI transaction support is present. |
| 39 | 3PL / WMS Integration (API + EDI 940/945/856) | `Missing` | Shipping documents exist, but there is no 3PL/WMS integration layer. |
| 40 | IoT / Sensor Data Integration for Predictive Maintenance | `Missing` | No IoT ingestion or predictive maintenance trigger path exists. |
| 41 | Supplier Portal with Lead-Time & Performance Tracking | `Missing` | Vendors are modeled internally, but there is no external supplier portal or ASN/confirmation workflow. |
| 42 | eCommerce Platform Integration | `Missing` | No Shopify/WooCommerce or marketplace sync exists. |
| 43 | Open REST API with Webhooks | `Partial` | Internal REST APIs exist, but not a documented public integration surface with webhooks and versioning guarantees. |
| 44 | CAD / PDM System Integration | `Missing` | No engineering-system import pipeline exists for BOMs or revisions. |
| 45 | Accounting / Tax Localization (Multi-Country) | `Partial` | Currency/tax fields exist, but not country-specific tax logic, fiscal localization, or e-invoicing support. |
### Tier 5: Analytics, UX & Platform Quality
| Rank | Feature | Status | CODEXIUM Position |
| --- | --- | --- | --- |
| 46 | Configurable Role-Based Dashboards with Real-Time KPIs | `Partial` | Dashboard widgets and rollups exist, but dashboards are not yet user-role configurable. |
| 47 | AI-Powered Planning Anomaly Detection | `Missing` | No AI/ML anomaly engine exists in planning. |
| 48 | Mobile-First Shop Floor Interface | `Partial` | The app is responsive, but not intentionally designed as a mobile-first execution surface for shop-floor work. |
| 49 | Project Capabilities (Cradle-to-Grave Project Tracking) | `Partial` | Projects are a first-class module and already linked across CRM, sales, shipping, and manufacturing, but milestones, cost rollups, procurement depth, and full cradle-to-grave control are still roadmap items. |
| 50 | Intuitive UX with Low Onboarding Time | `Partial` | Searchable pickers, dense operational layouts, and modular navigation are good foundations, but the product still needs more guided workflows and role-tailored experiences to claim this as a true market strength. |
## What CODEXIUM Already Does Well
These are the strongest market-facing talking points today:
1. End-to-end modular manufacturing workflow already exists in one codebase.
2. Demand planning is deeper than a typical early-stage MRP because it already supports BOM explosion, pegged supply, and build/buy recommendations.
3. Inventory location visibility, reservations, and transfer flows are already operational.
4. Projects are already first-class, which is a major differentiator versus many ERP foundations.
5. Work orders already post real inventory transactions for issue and completion.
6. Documents, approvals, revisions, attachments, audit, diagnostics, and brandable PDFs are unusually mature for this product stage.
7. Single-container deployment and SQLite-backed portability make the system operationally simple for smaller manufacturers.
## Missing Features and How To Achieve Them
The list below focuses on practical product moves rather than abstract wish-list items.
### 1. Deepen Planning Into A Real Scheduling Engine
Missing or weak features:
- demand forecasting
- master production scheduling
- finite capacity scheduling
- scenario planning
- ATP / delivery-date estimation
- multi-site planning
How to achieve it:
- Extend `planning` into a true planning engine instead of a gantt-only visibility layer.
- Add `forecast` entities and time buckets under `server/src/modules/planning`.
- Add `workCenter`, `calendar`, `shift`, `capacityBucket`, and `constraint` models under `manufacturing`.
- Build an MPS layer that sits between sales/project demand and detailed work-order generation.
- Add an ATP service that evaluates open supply, capacity, and purchased lead times before returning a promise date.
- Expand the current demand-planning recommendation engine to support reorder policies, time fences, and alternate scenarios.
Recommended roadmap placement:
- Near-term after current manufacturing routing/work-center work
- Belongs mostly in existing `planning` and `manufacturing` modules
### 2. Make Inventory Planning-Grade and Recall-Grade
Missing or weak features:
- lot/serial traceability
- negative stock prevention
- FIFO/FEFO rules
- cycle counting
- barcode workflows
- recall reporting
- consignment / VMI
How to achieve it:
- Add lot and serial models, lot-controlled transactions, and lot-resolved reservations under `inventory`.
- Enforce non-negative stock in issue/transfer/reservation services, not just in UI validation.
- Add inventory policy settings per item: lot-controlled, serial-controlled, FIFO, FEFO, expiry, consignment.
- Build count-session entities and lightweight count-task screens for warehouse execution.
- Introduce barcode-first receiving/issue/transfer/count UI states in the inventory and manufacturing modules.
- Once lot genealogy exists, add trace reports for backward/forward lot resolution and recall impact.
Recommended roadmap placement:
- High priority if targeting food, medical, chemical, or aerospace-adjacent manufacturers
- Belongs in `inventory`, with trace cross-links into `manufacturing`, `purchasing`, and `shipping`
### 3. Expand Manufacturing From Work Orders Into Shop-Floor Execution
Missing or weak features:
- labor tracking
- job costing
- estimate-vs-actual cost reporting
- scrap/yield
- digital instructions
- quality checkpoints
- NCR/CAPA
- tooling
- subcontract operations
How to achieve it:
- Extend `manufacturing` with `workCenter`, `routingStep`, `laborEntry`, `machineTime`, `scrapEvent`, `yieldSnapshot`, and `qualityInspection`.
- Add operation-level start/pause/complete execution screens and operator queue views.
- Add cost-rollup services that combine issued material, labor time, and burden rates.
- Reuse attachments plus revision metadata to create a formal digital instruction panel on the work-order operation screen.
- Add NCR/CAPA and inspection models either under `manufacturing` or a new `quality` module.
- Add outside-processing routing steps that generate supplier-facing purchasing events.
Recommended roadmap placement:
- Direct extension of the current manufacturing roadmap
- Best implemented as deeper `manufacturing` capability, with a likely new `quality` subdomain
### 4. Add Engineering Change and Product-Control Depth
Missing or weak features:
- BOM version control
- ECO workflow
- CAD/PDM integration
- configurable products / CPQ
- tooling dependencies
How to achieve it:
- Add revision-controlled BOM headers and effectivity dates in `inventory`.
- Add ECO request, approval, release, and effective-change propagation into BOM/routing/work-order data.
- Build import connectors for engineering structures from CAD/PDM exports first, then native integrations later.
- Introduce configurable product rules in `sales` plus generated BOM/routing outcomes in `inventory` and `manufacturing`.
Recommended roadmap placement:
- Mid-term
- Primarily `inventory`, `sales`, and `manufacturing`
### 5. Build the Integration Surface Buyers Expect
Missing or weak features:
- accounting integration
- supplier portal
- EDI
- 3PL / WMS integration
- eCommerce sync
- public API + webhooks
- IoT maintenance feeds
How to achieve it:
- Formalize the current internal API into a versioned integration layer.
- Add outbound webhook events for order approved, PO created, receipt posted, WO released, shipment shipped, inventory changed.
- Add integration adapters under a new `integrations` module rather than burying them in domain services.
- Start with REST + webhooks and CSV import/export for fastest market progress.
- Follow with targeted adapters: QuickBooks/Xero, Shopify, 3PL APIs, supplier confirmations/ASN, and later EDI.
Recommended roadmap placement:
- Mid-term, but public API/webhooks should move earlier because they unblock partner/customer integrations
- Best as a new top-level backend domain: `server/src/modules/integrations`
### 6. Turn Projects Into A Real ETO / Program-Control Advantage
Missing or weak features:
- milestones
- project rollups
- project-level costs
- project material view
- project execution cockpit
How to achieve it:
- Deliver the already-planned milestones and rollups first.
- Add project-level commercial, supply, manufacturing, and shipment summary services.
- Add project cost buckets sourced from sales, purchasing, manufacturing, and shipping transactions.
- Add project readiness and shortage boards for long-running builds.
Recommended roadmap placement:
- Immediate priority
- Extends the existing `projects` module and strengthens one of CODEXIUMs best differentiators
### 7. Improve Daily Adoption Through Role-Specific UX
Missing or weak features:
- configurable dashboards
- mobile-first shop-floor screens
- guided low-onboarding workflows
- anomaly detection
How to achieve it:
- Add saved dashboard layouts and role-default widget packs under `dashboard`.
- Build dedicated mobile execution screens for receiving, issue, completion, counts, and labor entry.
- Add contextual “next step” actions and exception queues per role instead of generic list/detail flows only.
- Add rule-based anomaly detection first, then ML later: late PO risk, overloaded work center, missing material, demand spike, suspicious lead-time drift.
Recommended roadmap placement:
- Split into near-term UX work and later AI work
- `dashboard`, `inventory`, `manufacturing`, and `planning` should all contribute
## Suggested Product Roadmap From This Analysis
### Phase A: Convert Foundation MRP Into Planning-Grade MRP
Priority additions:
- project milestones and project rollups
- work-center and routing depth
- labor capture
- capacity-aware scheduling
- reorder-policy purchasing
- non-negative stock enforcement
- BOM revisions
Why this phase matters:
- It strengthens current modules without requiring new market-facing verticals.
- It closes the biggest credibility gaps in planning and manufacturing execution.
### Phase B: Make The System Traceable and Shop-Floor Ready
Priority additions:
- lot/serial traceability
- barcode workflows
- cycle counting
- digital operator dispatch
- scrap/yield
- quality inspections
- job costing
Why this phase matters:
- It moves CODEXIUM from “good operational foundation” to “usable manufacturing system” for more serious buyers.
### Phase C: Build The ETO / Multi-Discipline Differentiator
Priority additions:
- project cockpit
- ECO workflow
- estimate-vs-actual project/job cost visibility
- subcontract processing
- ATP
- scenario planning
Why this phase matters:
- It leans into CODEXIUMs strongest differentiator: projects + manufacturing + planning in one product.
### Phase D: Open The Platform
Priority additions:
- versioned public REST API
- webhooks
- accounting integration
- supplier portal
- 3PL integration
- eCommerce sync
Why this phase matters:
- It improves commercial viability and reduces objections in buyer evaluations.
## Feature Gap Analysis By Manufacturing Type
### Make-to-Stock (MTS)
Current fit:
- moderate
Strengths:
- stock visibility
- reservations
- transfers
- purchasing and sales document flow
Biggest gaps:
- forecasting
- reorder automation
- FIFO/FEFO
- count discipline
### Make-to-Order (MTO)
Current fit:
- moderate to strong
Strengths:
- order-driven demand planning
- quote/order/project/manufacturing linkage
- shipment linkage
Biggest gaps:
- ATP
- job costing
- labor tracking
- deeper production dispatch
### Configure-to-Order (CTO)
Current fit:
- weak
Biggest gaps:
- CPQ
- revision-controlled engineering changes
- CAD/PDM integration
### Engineer-to-Order (ETO)
Current fit:
- moderate foundation, high upside
Strengths:
- projects are already first-class
- commercial/manufacturing/shipping cross-links already exist
Biggest gaps:
- milestones
- cost rollups
- ECO
- subcontracting
- CPQ/configuration logic
### Process Manufacturing
Current fit:
- weak
Biggest gaps:
- lots
- expiry
- FEFO
- recall traceability
- yield and quality controls
### Discrete / Job Shop
Current fit:
- moderate foundation
Strengths:
- work orders
- stations
- operation templates
- issue/completion posting
Biggest gaps:
- finite scheduling
- labor tracking
- job costing
- tooling
- dispatch UI
### Multi-Site Enterprise
Current fit:
- weak to moderate
Strengths:
- warehouses and locations exist
Biggest gaps:
- plant-aware planning
- inter-site planning logic
- enterprise integrations
- supplier/3PL/EDI ecosystem support
## Bottom Line
CODEXIUM is already credible as a modular manufacturing operations foundation and has unusually strong project, planning, and cross-module linkage for its current maturity level.
Where it is strongest today:
- small to mid-sized discrete manufacturers
- make-to-order environments
- project-linked manufacturing operations
- organizations that value deployment simplicity and integrated workflows over enterprise breadth
Where it is not yet market-complete:
- advanced scheduling
- regulated traceability
- shop-floor labor/cost/quality depth
- engineering control
- enterprise integrations
Best strategic path:
1. Double down on current strengths by deepening planning, projects, and manufacturing first.
2. Add traceability and execution discipline next to broaden manufacturing fit.
3. Open the integration layer after the core operational model is more mature.
That sequence gives CODEXIUM the clearest path from strong foundation MRP to differentiated manufacturing platform.

107
README.md
View File

@@ -1,46 +1,53 @@
# MRP Codex # CODEXIUM
Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment. Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment.
## Documentation Maintenance ## Documentation Maintenance
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated for shipped features, workflow changes, and notable operational updates. - Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated for shipped features, workflow changes, and notable operational updates.
- Keep [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) aligned when changes affect their scope. - Keep [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) aligned when changes affect their scope.
Current foundation scope includes: Current foundation scope includes:
- authentication and RBAC - authentication and RBAC
- company branding and theme settings - company branding and theme settings, including startup brand-theme hydration across refresh
- CRM customers and vendors with create/edit/detail workflows - CRM customers and vendors with create/edit/detail workflows
- CRM search, filtering, status tagging, and reseller hierarchy - CRM search, filtering, status tagging, and reseller hierarchy
- CRM contact history, account contacts, and shared attachments - CRM contact history, account contacts, and shared attachments
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows - inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
- inventory transfers, reservations, and available-stock visibility - inventory transfers, reservations, and available-stock visibility
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
- staged thumbnail image attachment on inventory item create/edit workflows, with detail-page thumbnail display
- sales quotes and sales orders with searchable customer and SKU entry - sales quotes and sales orders with searchable customer and SKU entry
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders - sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
- 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-order revision history and revision comparison across commercial and receipt changes
- purchase receiving with warehouse/location posting and receipt history against purchase orders - purchase receiving with warehouse/location posting and receipt history against purchase orders
- finance with sales-order-linked customer payments, live purchasing/manufacturing spend rollups, costing assumptions, and CapEx tracking for equipment, tooling, and consumables
- 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 - 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 inventory-backed picking, stock issue posting, 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, milestones, rollups, inline milestone quick-status actions, notes, attachments, reverse-linked quote/sales-order visibility, and downstream project-context carry-through into generated work orders and purchase orders
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments - manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, required hold reasons for `On Hold` status changes, material issue posting, completion posting, operation rescheduling, and work-order attachments
- planning gantt timelines with live project and manufacturing schedule data - planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts - planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items - pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing - shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, review filtering, and unusual-access cues
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page - CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow - backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics - backup verification checklist and restore-drill runbook surfaced in admin diagnostics
- support-log viewing and support debugging helpers in admin diagnostics - support-log viewing, filtering, retention cleanup, and richer support-debug export helpers in admin diagnostics
- route-level code-splitting and vendor chunking for lighter initial client loads - route-level code-splitting and vendor chunking for lighter initial client loads
- file storage and PDF rendering - file storage and PDF rendering
## Product Map ## Product Map
Shipped phase history now lives in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md). [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) now tracks remaining work only.
Current completed foundation areas: Current completed foundation areas:
- dashboard foundation - dashboard foundation
@@ -57,14 +64,13 @@ Current completed foundation areas:
Near-term priorities: Near-term priorities:
1. Better user and session visibility for operational admins 1. Deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
2. Safer destructive-action confirmations and recovery messaging 2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
Revisit / deferred items: Revisit / deferred items:
- local Windows Prisma migration reliability - local Windows Prisma migration reliability
- better user and session visibility for operational admins - deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
- safer destructive-action confirmations and recovery messaging
Dashboard direction: Dashboard direction:
@@ -75,7 +81,7 @@ Dashboard direction:
- richer recent-activity widgets and exception queues are a planned QOL follow-up, not a separate landing-page redesign - richer recent-activity widgets and exception queues are a planned QOL follow-up, not a separate landing-page redesign
- projects now feed dashboard widgets for active programs, overdue work, and risk - projects now feed dashboard widgets for active programs, overdue work, and risk
- manufacturing now feeds dashboard widgets for released work, overdue orders, and execution load - manufacturing now feeds dashboard widgets for released work, overdue orders, and execution load
- planning now feeds live gantt scheduling from project and manufacturing records - planning now feeds the live workbench schedule from project and manufacturing records
- future project widgets should deepen milestones, shortages, and shipment readiness - future project widgets should deepen milestones, shortages, and shipment readiness
Navigation direction: Navigation direction:
@@ -83,28 +89,50 @@ Navigation direction:
- module navigation now uses inline SVG icons alongside labels - module navigation now uses inline SVG icons alongside labels
- new modules should add a clear, domain-appropriate SVG icon when they are added to the shell - new modules should add a clear, domain-appropriate SVG icon when they are added to the shell
- icons should stay lightweight, theme-aware, and dependency-free unless there is a strong reason to introduce a shared icon package - icons should stay lightweight, theme-aware, and dependency-free unless there is a strong reason to introduce a shared icon package
- active operational screens should default to a denser layout baseline with tighter card padding, smaller inter-panel gaps, and less decorative negative space
- module headers and section labels should prefer uppercase naming and concise operational wording instead of redundant explanatory subcopy inside the working interface
## Finance Direction
Finance is now a first-class domain for commercial cash tracking and capital planning rather than a hidden report stitched together from sales and purchasing screens. The current slice ships sales-order-linked payment posting, labor/overhead costing assumptions, cross-linked revenue versus purchasing/manufacturing spend rollups, and CapEx tracking for equipment, tooling, and consumables with optional purchase-order linkage.
Current interactions:
- Sales: customer receipts post against sales orders and update finance-ledger visibility for booked revenue, payments received, and open A/R
- Purchasing: linked PO lines contribute committed and received spend visibility to the sales-order finance ledger
- Manufacturing: issued material and recorded labor drive derived manufacturing/assembly cost rollups using finance-side labor and overhead assumptions
- Dashboard direction: finance should later contribute margin, cash, CapEx, and payment-risk widgets without replacing the operational dashboard
Next expansion areas:
- AP-side disbursements, invoice matching, and vendor payment workflows
- More granular manufacturing costing with crew rates, burden rules, and variance reporting
- Project-level P&L and earned-value style rollups across commercial, supply, and execution
- Accounting export/integration once the internal finance operating model is deeper
## Projects Direction ## Projects Direction
Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, notes, commercial document links, shipment links, attachments, and dashboard visibility. Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery/purchasing visibility, readiness-risk scoring, a cost snapshot from linked purchasing and manufacturing data, direct launch paths into prefilled purchasing/manufacturing follow-through, an activity timeline across linked execution records, notes, commercial document links, shipment links, attachments, and dashboard visibility.
Current interactions: Current interactions:
- CRM: each project should link to a customer account and relevant contacts - CRM: each project should link to a customer account and relevant contacts
- Sales: quotes and sales orders can already attach to projects - Sales: quotes and sales orders can already attach to projects
- Sales workflow now also exposes the reverse project link on quote and sales-order detail pages, and quote conversion carries linked project context forward into the created sales order
- Shipping: shipments tied to project deliverables are visible from the project record - Shipping: shipments tied to project deliverables are visible from the project record
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets - Dashboard: projects now contribute status, risk, backlog, and overdue widgets
- Detail/List UX: projects now surface milestone progress and linked execution rollups
Next expansion areas: Next expansion areas:
- Inventory: projects should reference item/BOM scope and later expose shortages or allocations - Inventory: projects should reference item/BOM scope and later expose shortages or allocations
- Purchasing: project material demand should be visible to purchasing and receiving workflows - Purchasing: project material demand is now visible through linked PO, receipt, vendor, and outstanding-supply rollups, and purchase orders now persist header-level project context derived from linked sales demand or explicit project selection
- Manufacturing: work orders should link back to projects without turning projects into the manufacturing module - Manufacturing: work orders already auto-link back to the project when the originating sales order belongs to a project, without turning projects into the manufacturing module
- Planning: project milestones and execution dates should feed gantt scheduling and dependency views - Planning: project milestones and execution dates should feed workbench scheduling, dependency views, and richer planner drilldowns
## Manufacturing Direction ## Manufacturing Direction
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, automatic work-order operation plans, material issue posting, completion posting, work-order attachments, and dashboard visibility. Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, editable station calendars and capacity settings, automatic work-order operation plans, operation-level execution controls, operator assignment, timer-based and manual labor posting, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility.
Current interactions: Current interactions:
@@ -116,16 +144,16 @@ Next expansion areas:
- Purchasing: shortages and buyout demand should surface from manufacturing execution - Purchasing: shortages and buyout demand should surface from manufacturing execution
- Shipping: completed manufacturing should feed shipment readiness - Shipping: completed manufacturing should feed shipment readiness
- Planning: manufacturing orders, routings, and work centers should drive capacity and schedule views - Planning: manufacturing orders, routings, and work centers now drive the first capacity/load layer and should continue expanding into fuller finite-capacity scheduling
## Planning Direction ## Planning Direction
Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a gantt surface backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, and exception cards for overdue or at-risk schedule items. Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, station-lane drag scheduling with projected load cues, planned-vs-actual station load visibility, inline release/build/buy follow-through, and agenda sequencing.
Current interactions: Current interactions:
- Projects: project timelines and due dates anchor the top-level planning rows - Projects: project timelines and due dates anchor the top-level planning rows
- Manufacturing: open work orders feed task rows, sequencing links, and execution progress - Manufacturing: open work orders feed task rows, sequencing links, execution progress, release-ready state, and station capacity load
- Dashboard: planning now appears as a first-class module with schedule visibility links - Dashboard: planning now appears as a first-class module with schedule visibility links
Next expansion areas: Next expansion areas:
@@ -175,7 +203,11 @@ Command-line build notes:
docker build --build-arg NODE_VERSION=22 -t mrp-codex . docker build --build-arg NODE_VERSION=22 -t mrp-codex .
``` ```
The container startup script runs `npx prisma migrate deploy` automatically before launching the server. The container startup script runs the server workspace Prisma binary directly:
```bash
/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma
```
This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM and inventory migrations on Windows. This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM and inventory migrations on Windows.
@@ -223,8 +255,7 @@ The current inventory foundation supports:
- item on-hand quantity, stock-by-location balances, and recent stock history - item on-hand quantity, stock-by-location balances, and recent stock history
- reserved and available quantity visibility by location - reserved and available quantity visibility by location
- item-level file attachments for drawings and support documents - item-level file attachments for drawings and support documents
- seeded sample inventory items and a starter assembly BOM during bootstrap - fresh bootstrap starts inventory and warehouse data empty so first-run environments do not include demo operational records
- seeded sample warehouse and stock locations during bootstrap
QOL direction: QOL direction:
@@ -287,6 +318,9 @@ The current shipping foundation supports:
- shipment list, detail, create, and edit flows - shipment list, detail, create, and edit flows
- searchable sales-order lookup instead of a static order dropdown - searchable sales-order lookup instead of a static order dropdown
- shipment records linked directly to sales orders - shipment records linked directly to sales orders
- shipment-line ordered, picked, and remaining quantity visibility
- warehouse/location-backed shipment picking with immediate stock issue posting
- shipment pick history tied to the inventory movement that fulfilled the shipment
- carrier, service level, tracking number, package count, notes, and ship date fields - carrier, service level, tracking number, package count, notes, and ship date fields
- shipment quick status actions from the shipment detail page - shipment quick status actions from the shipment detail page
- related-shipment visibility from the sales-order detail page - related-shipment visibility from the sales-order detail page
@@ -325,7 +359,7 @@ Logo uploads are stored through the authenticated file pipeline and are rendered
- Apply committed migrations in production: `npm run prisma:deploy` - Apply committed migrations in production: `npm run prisma:deploy`
- If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth. - If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth.
As of March 14, 2026, the latest committed domain migrations include: As of March 15, 2026, the latest committed domain migrations include:
- CRM status and list filters - CRM status and list filters
- CRM contact-history timeline - CRM contact-history timeline
@@ -345,7 +379,12 @@ As of March 14, 2026, the latest committed domain migrations include:
- shipping foundation - shipping foundation
- projects foundation - projects foundation
- manufacturing foundation - manufacturing foundation
- planning foundation - manufacturing stations and operation templates
- inventory transfers and reservations
- audit trail and diagnostics foundation
- auth-session visibility and revocation
- session review filters, unusual-access cues, and startup pruning of stale expired/revoked session records
- supply pegging and preferred-vendor sourcing
Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment. Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment.
@@ -358,23 +397,26 @@ The current admin operations slice supports:
- a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations - a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations
- prefilled work-order and purchase-order draft launch paths from sales-order demand-planning recommendations - prefilled work-order and purchase-order draft launch paths from sales-order demand-planning recommendations
- shared shortage/readiness rollups across planning, project, purchasing, dashboard, and manufacturing views - shared shortage/readiness rollups across planning, project, purchasing, dashboard, and manufacturing views
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration - a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, role-permission administration, and session visibility/revocation
- session review filters and flagged cues for stale activity, multi-session overlap, and multi-IP access patterns
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail - CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
- startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot - startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
- startup now prunes stale expired or revoked auth-session records before serving requests
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff - backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff
- support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review - support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review
- support logs now support admin-side filtering by severity, source, search text, and retention window, and exports include summary metadata
- backup verification items and restore-drill expected outcomes now live in the admin runbook surface - backup verification items and restore-drill expected outcomes now live in the admin runbook surface
- operator-facing review of recent high-impact changes without direct database access - operator-facing review of recent high-impact changes without direct database access
Current follow-up direction: Current follow-up direction:
- better user and session visibility for operational admins - revision comparison UX for changed sales and purchasing documents
- safer destructive-action confirmations and recovery messaging - deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
## UI Notes ## UI Notes
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation. - Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, projects, manufacturing, settings, and planning modules from the same app shell. - The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, finance, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
- The active module screens now follow a tighter density baseline for forms, tables, and detail cards. - The active module screens now follow a tighter density baseline for forms, tables, and detail cards.
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data. - The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data.
- The client now ships with route-level lazy loading and vendor chunking, so future frontend work should preserve that split instead of re-centralizing module imports in `main.tsx`. - The client now ships with route-level lazy loading and vendor chunking, so future frontend work should preserve that split instead of re-centralizing module imports in `main.tsx`.
@@ -382,3 +424,4 @@ Current follow-up direction:
## PDF Generation ## PDF Generation
Puppeteer is used by the backend to render HTML templates into professional PDFs. The current PDF surface includes the branded company-profile preview, sales quotes, sales orders, purchase orders, shipment packing slips, shipping labels, and bills of lading. The Docker image includes Chromium runtime dependencies required for headless execution. Puppeteer is used by the backend to render HTML templates into professional PDFs. The current PDF surface includes the branded company-profile preview, sales quotes, sales orders, purchase orders, shipment packing slips, shipping labels, and bills of lading. The Docker image includes Chromium runtime dependencies required for headless execution.

View File

@@ -3,227 +3,106 @@
## Documentation maintenance ## Documentation maintenance
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated alongside roadmap-driving feature completion, priority shifts, and notable delivery milestones. - Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated alongside roadmap-driving feature completion, priority shifts, and notable delivery milestones.
- Keep [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md) updated when roadmap items move from planned to delivered.
- When roadmap changes affect implementation guidance or deployment expectations, update the companion docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) in the same change set. - When roadmap changes affect implementation guidance or deployment expectations, update the companion docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) in the same change set.
## Product direction ## Product direction
MRP Codex is being built as a streamlined, modular manufacturing resource planning platform with strong branding controls, fast operational workflows, and a single-container deployment model that is simple to back up and upgrade. CODEXIUM is being built as a streamlined, modular manufacturing resource planning platform with strong branding controls, fast operational workflows, and a single-container deployment model that is simple to back up and upgrade.
## Current status This file tracks work that still needs to be completed. Shipped phase history and completed slices now live in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md).
### Completed: Foundation release ## Near-term priority order
- Monorepo-style workspace with `client`, `server`, and `shared` 1. Manufacturing costing and execution depth, including scrap/rework/yield tracking and variance visibility
- React + Vite + Tailwind frontend shell 2. Finance expansion across AP disbursements, invoice matching, vendor payments, and project-level P&L
- Express + TypeScript backend shell 3. Workbench finite-capacity intelligence, including conflict handling, queue-slot guidance, and auto-rebalance recommendations
- Prisma + SQLite schema foundation with committed initial migration 4. Dashboard KPI, alert, recent-activity, and exception-widget expansion, especially for finance, manufacturing, and planning
- Local authentication with JWT-based session flow 5. Longer-term session history and audit depth beyond the current review filtering and retention cleanup
- RBAC permission model and protected routes
- Central Company Settings with runtime branding controls
- Light and dark mode theme system
- Local file attachment storage under `/app/data/uploads`
- Puppeteer PDF service foundation with branded company-profile preview
- CRM reference entities for customers and vendors
- CRM customer and vendor create/edit/detail workflows
- CRM search, filters, and persisted status tagging
- CRM contact-history timeline with authored notes, calls, emails, and meetings
- CRM shared file attachments on customer and vendor records, including delete support
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
- Inventory item master, BOM, warehouse, and stock-location foundation
- Inventory transactions, on-hand tracking, and item attachments
- Inventory transfers, reservations, available-stock visibility, and work-order-driven material reservation automation
- Sales quotes and sales orders with commercial totals logic
- Purchase orders with vendor lookup, item lines, totals, and quick status actions
- Purchase-order line selection restricted to inventory items flagged as purchasable
- Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking
- Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline
- Shipping shipment records linked to sales orders
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
- Logistics attachments directly on shipment records
- 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
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance and exportable support bundles surfaced through the admin diagnostics workflow
- Backup verification checklist and restore-drill runbook surfaced through the admin diagnostics workflow
- Support-log viewing for startup warnings, HTTP failures, and server errors surfaced through the admin diagnostics workflow
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
- Live planning gantt timelines driven by project and manufacturing data
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
### Current known gaps in the foundation ## Active roadmap
- 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 ### Platform and operational docs
- CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
- The current sales/purchasing/shipping foundation now includes sales approvals and revision history, but still needs vendor exception handling, deeper carrier integration, and richer document comparison tooling
- 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 manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins
## Dashboard Plan - Keep the Windows Prisma migration workflow clearer and less fragile for local contributors
- Continue tightening backup, restore, and support-runbook guidance as operations maturity grows
- Preserve the single-container deployment path while improving diagnostics and supportability
- Keep `Dashboard` as the primary landing surface for operators ### Dashboard
- Expand it by modular panels rather than redesigning it for each new feature phase
- Prefer metric cards, exception queues, action shortcuts, and status summaries over static descriptive content
- Add future widgets for purchasing, shipping exceptions, inventory shortages, planning readiness, and audit/system health
- Continue expanding the new project widgets into milestone, blockage, and shipment-readiness views instead of creating a separate landing area
- Continue expanding the new manufacturing widgets into shortage, routing, and bottleneck views instead of creating a separate landing area
- Treat dashboard modules as upgradeable blocks that can be reordered or expanded without disturbing the shell
## Planned feature phases - Expand `Dashboard` by modular panels rather than redesigning it into a different shell
- Add richer KPI widgets, alerts, recent-activity queues, and exception reporting
- Add deeper project, manufacturing, purchasing, finance, shipping, and audit/system-health widgets
### Phase 1: CRM and master data hardening ### CRM and master data
- Better seed/bootstrap strategy for non-development environments - Better seed/bootstrap strategy for non-development environments
- Additional CRM account-role depth if later sales/purchasing workflows need it - Additional CRM account-role depth if later sales/purchasing workflows need it
- More derived CRM rollups once quotes, orders, and purchasing documents exist - More derived CRM rollups once downstream quote/order/purchasing/shipping data grows further
QOL subfeatures:
- Saved CRM filters and quick views - Saved CRM filters and quick views
- Better hierarchy navigation between reseller parents and child accounts - Better hierarchy navigation between reseller parents and child accounts
- One-click contact actions for email and phone workflows - One-click contact actions for email and phone workflows
- Duplicate-account detection and merge workflow - Duplicate-account detection and merge workflow
- Cleaner attachment previews and richer record timelines - Cleaner attachment previews and richer record timelines
- More compact table controls for heavy CRM data-entry users - More compact table controls for heavy CRM data-entry users
- CRM document rollups and broader account-role depth that were deferred until downstream modules matured
### Phase 2: Inventory and manufacturing core ### Inventory
- Item master and SKU structure foundation - Item master enrichment: categories, alternate part numbers, revisions, reorder settings, and broader sourcing metadata
- Warehouse and stock location foundation - Faster keyboard-heavy item/BOM entry refinement beyond the current searchable pickers
- Inventory transactions and on-hand tracking foundation - Better warehouse dashboards for on-hand, shortages, reservations, and recent movement
- Bills of materials and custom assemblies foundation
- File attachments for BOM drawings and manufacturing support docs foundation
QOL subfeatures:
- Item master enrichment: categories, alternate part numbers, revisions, preferred vendor data, and reorder settings
- Stock transfers between warehouses and locations
- Reservation and allocation visibility against demand
- Faster SKU search and keyboard-heavy item/BOM entry flows refinement
- Better warehouse dashboards for on-hand, shortages, and recent movement
- BOM revision support and clearer where-used visibility - BOM revision support and clearer where-used visibility
- Bulk item import/export and mass-update utilities - Bulk item import/export and mass-update utilities
### Phase 3: Sales and purchasing documents ### Sales and purchasing
- Quotes, sales orders, and purchase orders
- Reusable line-item and totals model
- Purchase receiving flow tied to purchase-order lines and inventory receipts foundation
- Document states, approvals, and revision history
- Branded PDF templates rendered through Puppeteer
- Attachments for vendor invoices and supporting documents
Foundation slice shipped:
- Sales approval stamps and automatic revision history on quotes and sales orders
- Purchase-order supporting documents through the shared attachment pipeline
- Vendor-detail purchasing visibility for recent purchase-order activity
QOL subfeatures:
- Vendor exception handling for acknowledgements, invoice matching, receipt discrepancies, and related inbound follow-up
- Deeper carrier/commercial defaults where they improve order-entry speed
- Line duplication, drag ordering, and keyboard-first line editing - Line duplication, drag ordering, and keyboard-first line editing
- Saved customer defaults for tax, freight, and commercial terms - Saved customer defaults for tax, freight, and commercial terms
- Inline stock visibility while building quotes and orders
- Restrict purchase-order item entry to purchasable inventory only
- Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions - Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions
- Better totals breakdown visibility on list pages and detail pages - 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 - Faster document cloning and quote-to-order style conversions across document types
### Phase 4: Shipping and logistics ### Finance
- Shipment records linked to sales orders - Expand from customer receipts into AP disbursements, invoice matching, and vendor-payment control
- Bills of lading, packing slips, and shipping BOM PDFs - Add project-level P&L, cash posture, and earned-value style rollups across sales, purchasing, manufacturing, and shipping
- Carrier, package, and tracking data - Deepen manufacturing costing with crew rates, burden rules, and variance reporting instead of only the current labor/overhead assumptions
- Outbound shipment status workflow - Add payment-status workflow depth on sales orders and linked finance cues on purchasing, manufacturing, shipping, and project records
- Scanned logistics-document attachment handling - Add accounting export or integration surfaces once the internal finance workflows mature
- Add richer dashboard widgets for margin pressure, open receivables, CapEx exposure, and payment coverage risk
QOL subfeatures: ### Shipping and logistics
- Printer-friendly reprint and history actions for logistics documents
- Partial shipment workflow and split-shipment visibility - Partial shipment workflow and split-shipment visibility
- Better tracking-link UX and carrier-specific shortcuts - Better tracking-link UX and carrier-specific shortcuts
- Packing verification and ship-confirm checkpoints - Packing verification and ship-confirm checkpoints
- Shipment search by order, tracking, customer, and carrier from one screen - Shipment search by order, tracking, customer, and carrier from one screen
- Reprint and history actions for generated logistics PDFs - Printer-friendly reprint/history actions for logistics documents
### Phase 5: Projects and program management ### Projects and program management
Foundation slice shipped:
- Project records with customer linkage, status, owner, priority, due dates, and notes
- Project-to-quote, sales-order, and shipment linkage for delivery context
- Project attachments through the shared file pipeline
- Project list/detail/create/edit flows and dashboard visibility
- Project document hub for drawings, support files, correspondence, and revision references - Project document hub for drawings, support files, correspondence, and revision references
- Milestones, checkpoints, and non-manufacturing work packages for long-running execution tracking - Non-manufacturing work packages for long-running execution tracking
- Project-level commercial, material, schedule, and delivery rollups - Deeper project-level cost, material, schedule, delivery, and finance rollups beyond the current purchasing/readiness cockpit
- Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication - Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication
Module interactions:
- CRM: projects link to customer accounts, reseller-owned end customers, contacts, and account notes
- Sales: quotes and sales orders can spawn or attach to projects; project status should reflect commercial state where relevant
- Inventory: projects reference item/BOM scope, expose shortage/reservation pressure, and later roll up material readiness
- Purchasing: projects surface buyout demand and vendor receipts tied to project material needs
- Shipping: shipments should be visible from the project record when a project drives deliverables
- Dashboard: projects add live widgets for active programs, overdue milestones, shortages, and blocked delivery
- Manufacturing: manufacturing orders and shop execution should link back to projects, but remain their own subsystem
- Gantt/planning: project milestones and execution dates should feed planning views without collapsing projects into scheduling alone
QOL subfeatures:
- Project templates for repeatable build types - Project templates for repeatable build types
- Project-specific attachment bundles and revision snapshots - Project-specific attachment bundles and revision snapshots
- One-screen project cockpit with commercial, material, schedule, and shipping summary - One-screen project cockpit with deeper cost, material, schedule, shipping, finance, and action-oriented summary workflows
- Better cross-links between project, customer, order, shipment, and inventory records - Better cross-links between project, customer, order, shipment, and inventory records
- Project filtering by customer, owner, status, due date, and risk - Project filtering by customer, owner, status, due date, and risk
- Project activity timeline and audit-friendly milestone history - Project activity timeline and audit-friendly milestone history
### Phase 6: Manufacturing execution ### Manufacturing execution
Foundation slice shipped: - Work orders tied more explicitly to sales demand or internal build demand where appropriate
- Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
- Work orders tied to manufactured or assembly items, with optional project linkage - Material consumption depth, WIP tracking, and execution traceability
- BOM-based material requirement visibility from the work-order record - Deeper labor depth beyond the shipped operator assignment and timer-based labor capture, including crew-level staffing, labor approvals, and machine/runtime integration
- Material issue posting that creates real inventory issue transactions - Planned-versus-actual material, labor, and overhead variance reporting shared with the finance module
- Production completion posting that creates finished-goods receipt transactions
- Work-order list/detail/create/edit flows, attachments, and dashboard visibility
- Work orders tied to projects, sales demand, or internal build demand
- Routing/work-center structure for manufacturing steps and handoffs
- Material issue, consumption, completion, and WIP tracking
- Labor and machine-time capture for production execution
- Manufacturing status workflow from release through completion
- Manufacturing rollups for open work, blockers, shortages, and throughput - Manufacturing rollups for open work, blockers, shortages, and throughput
Module interactions:
- Projects: manufacturing orders can be attached to projects, but projects remain the higher-level long-running record
- Inventory: manufacturing consumes components and produces finished/semi-finished stock
- Purchasing: shortages and buyout demand should be visible from manufacturing execution
- Shipping: completed manufacturing should feed shipment readiness, but shipping remains separate
- Dashboard: manufacturing adds live queues for open jobs, blocked work, overdue orders, and completion throughput
- Planning: manufacturing orders and routings become a major input into capacity and gantt scheduling
QOL subfeatures:
- Traveler/job packet output - Traveler/job packet output
- Partial completions and split-order execution visibility - Partial completions and split-order execution visibility
- Better shortage and substitute-part handling - Better shortage and substitute-part handling
@@ -231,88 +110,34 @@ QOL subfeatures:
- Rework / hold / scrap tracking - Rework / hold / scrap tracking
- Work-center dashboards and operator-focused queues - Work-center dashboards and operator-focused queues
### Phase 7: Planning and scheduling ### Planning and scheduling
Foundation slice shipped: - Standardize dense UI primitives and shared page shells so future Workbench, dashboard, and operational screens reuse the same cards, filter bars, empty states, and section wrappers instead of reintroducing ad hoc layout patterns
- Live gantt schedule backed by active projects and open manufacturing work orders
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
- Planning exception queue for overdue or at-risk project/manufacturing schedule items
- Live project-backed SVAR gantt timelines
- Task dependencies, milestones, and progress updates - Task dependencies, milestones, and progress updates
- Manufacturing calendar views and bottleneck visibility - Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
- Labor and machine scheduling support - Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
- Theme-compliant gantt customization for light/dark mode
QOL subfeatures:
- Collapsible schedule groupings and saved planner views - Collapsible schedule groupings and saved planner views
- Drag-and-drop rescheduling improvements - Richer conflict handling, queue-slot suggestions, and auto-rebalance logic beyond the shipped station-lane drag scheduling
- Best-next-slot and best-alternate-station recommendations for planners handling overload and blockers
- Critical-path and overdue highlighting - Critical-path and overdue highlighting
- Capacity warnings for overloaded work centers - Richer finite-capacity warnings, automated rebalance logic, and station drag-rescheduling beyond the shipped overload indicators and workbench rebalance controls
- Deeper material readiness, pegged-supply, and dispatch recommendation visibility inside Workbench rows and focus panels
- Better mobile and tablet behavior for shop-floor lookups - Better mobile and tablet behavior for shop-floor lookups
- Faster filtering by project, customer, work center, and status - Faster filtering by project, customer, work center, and status
### Phase 8: Demand planning and supply generation ### Demand planning and supply generation
Foundation slice shipped: - Deeper planner drilldowns from demand source to buy/build action without re-keying data
- Sales-order demand planning from approved or active demand records
- Multi-level BOM explosion from sales-order lines through manufactured and assembly children
- Netting against available stock, active reservations, open work orders, and open purchase orders
- Build and buy recommendations surfaced directly from the sales-order workflow
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
- Pegged work-order and purchase-order supply links back to originating sales demand
- Planning recommendations now reduce against already-linked draft/open supply to avoid duplicate WO/PO generation
- Shared MRP demand engine across sales, inventory, purchasing, manufacturing, projects, and planning
- Planned work-order and purchase-order recommendation generation
- Coverage, shortage, and lateness rollups from customer demand down through supply layers
- Cross-module shortage visibility on sales orders, projects, work orders, purchasing, and dashboard widgets
QOL subfeatures:
- One-click conversion of planning recommendations into work orders and purchase orders
- Better shortage and substitute-part guidance during planning review - Better shortage and substitute-part guidance during planning review
- Saved planning views by customer, project, item family, and shortage state - Saved planning views by customer, project, item family, and shortage state
- Planner-focused drilldowns from demand source to buy/build action without re-keying data
- Time-phased supply recommendations with vendor lead times and build timing - Time-phased supply recommendations with vendor lead times and build timing
### Phase 9: Security, audit, and operations maturity ### Security, audit, and operations maturity
Foundation slice shipped: - Admin diagnostics depth for permissions, migrations, storage, and PDF health
- Longer-term session history and audit depth beyond the current review filtering and retention cleanup
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
- Expanded role-management UI with account creation, activation, role assignment, and permission administration
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance, support-bundle exports, and support-log viewing surfaced through the admin diagnostics workflow
- Expanded role management UI
- Permission assignment administration
- Audit trail coverage across critical records
- Backup/restore workflow documentation and scripts
- Health checks, startup diagnostics, and production readiness cleanup
QOL subfeatures:
- Admin diagnostics screen for permissions, migrations, storage, and PDF health
- Safer destructive-action confirmations and recovery messaging
- Better user/session visibility for operational admins
- More explicit environment validation on startup - More explicit environment validation on startup
- Support-log filtering, retention controls, and broader support-package polish - Backup verification and restore-drill guidance should keep expanding as the system grows
- Backup verification checklist and restore drill guidance
## Revisit / Deferred Items
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
- Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred
## Cross-cutting improvements ## Cross-cutting improvements
@@ -323,7 +148,8 @@ QOL subfeatures:
- Consistent document-template system shared by sales, purchasing, and shipping - Consistent document-template system shared by sales, purchasing, and shipping
- Clear upgrade path for future module additions without refactoring the app shell - Clear upgrade path for future module additions without refactoring the app shell
## Near-term priority order ## Revisit / Deferred Items
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
1. Better user and session visibility for operational admins
2. Safer destructive-action confirmations and recovery messaging

141
SHIPPED.md Normal file
View File

@@ -0,0 +1,141 @@
# Shipped
This file tracks roadmap phases, slices, and major foundations that have already shipped. Remaining work lives in [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md).
## Foundation release
- Monorepo-style workspace with `client`, `server`, and `shared`
- React + Vite + Tailwind frontend shell
- Express + TypeScript backend shell
- Prisma + SQLite schema foundation with committed initial migration
- Local authentication with JWT-based session flow plus persisted session visibility and revocation
- RBAC permission model and protected routes
- Central Company Settings with runtime branding controls
- Light and dark mode theme system
- Local file attachment storage under `/app/data/uploads`
- Puppeteer PDF service foundation with branded company-profile preview
- CRM reference entities for customers and vendors
- CRM customer and vendor create/edit/detail workflows
- CRM search, filters, and persisted status tagging
- CRM contact-history timeline with authored notes, calls, emails, and meetings
- CRM shared file attachments on customer and vendor records, including delete support
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
- Inventory item master, BOM, warehouse, and stock-location foundation
- Inventory transactions, on-hand tracking, and item attachments
- Inventory transfers, reservations, available-stock visibility, and work-order-driven material reservation automation
- Sales quotes and sales orders with commercial totals logic
- Purchase orders with vendor lookup, item lines, totals, and quick status actions
- Purchase-order line selection restricted to inventory items flagged as purchasable
- Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking
- Finance module with sales-order-linked customer payments, live spend/margin rollups across linked purchase orders and manufacturing, finance costing assumptions, and CapEx tracking for equipment, tooling, and consumables
- Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline
- Shipping shipment records linked to sales orders
- Inventory-backed shipment picking with stock issue posting from warehouse locations and shipment-side pick history
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
- Logistics attachments directly on shipment records
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
- Reverse project linkage visibility on quote and sales-order detail pages, plus project-context carry-through into generated work orders and purchase orders with sales-order-driven backfill for existing records
- Project milestones, inline milestone quick-status actions, and project-side milestone/work-order rollups
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
- Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, operation execution controls, operator assignment, timer-based and manual labor posting, required hold reasons for `On Hold` status changes, material issue posting, completion posting, and work-order attachments
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance and exportable support bundles surfaced through the admin diagnostics workflow
- Backup verification checklist and restore-drill runbook surfaced through the admin diagnostics workflow
- Support-log viewing for startup warnings, HTTP failures, and server errors surfaced through the admin diagnostics workflow
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
- Startup brand-theme hydration so Company Settings colors and font persist correctly across refresh
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
- Live planning workbench timelines driven by project and manufacturing data
- Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions
- Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling
- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, station-lane drag scheduling, and planned-vs-actual station load visibility
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
## Shipped roadmap phases
### Phase 3: Sales and purchasing documents
- Sales approval stamps and automatic revision history on quotes and sales orders
- Purchase-order supporting documents through the shared attachment pipeline
- Vendor-detail purchasing visibility for recent purchase-order activity
### Phase 5: Projects and program management
- Project records with customer linkage, status, owner, priority, due dates, milestones, and notes
- Project milestone status tracking and project-side milestone/work-order rollups
- Project-to-quote, sales-order, and shipment linkage for delivery context
- Quote-to-sales-order project carry-through plus reverse-linked project visibility from the sales workflow
- Project attachments through the shared file pipeline
- Project list/detail/create/edit flows and dashboard visibility
### Phase 6: Manufacturing execution
- Work orders tied to manufactured or assembly items, with optional project linkage
- BOM-based material requirement visibility from the work-order record
- Material issue posting that creates real inventory issue transactions
- Production completion posting that creates finished-goods receipt transactions
- Work-order list/detail/create/edit flows, attachments, and dashboard visibility
### Phase 7: Planning and scheduling
- Live workbench schedule backed by active projects and open manufacturing work orders
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
- Planning exception queue for overdue or at-risk project/manufacturing schedule items
- Station load summaries, release-ready visibility, and inline workbench follow-through actions for release/build/buy dispatch
### Phase 8: Demand planning and supply generation
- Sales-order demand planning from approved or active demand records
- Multi-level BOM explosion from sales-order lines through manufactured and assembly children
- Netting against available stock, active reservations, open work orders, and open purchase orders
- Build and buy recommendations surfaced directly from the sales-order workflow
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
- Generated work orders and purchase orders now auto-carry linked project context when demand traces back to a project-linked sales order
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
- Pegged work-order and purchase-order supply links back to originating sales demand
- Planning recommendations now reduce against already-linked draft/open supply to avoid duplicate WO/PO generation
### Phase 9: Security, audit, and operations maturity
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
- Expanded role-management UI with account creation, activation, role assignment, and permission administration
- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins
- Server-side logout and admin session revocation for JWT-backed access
- Session review filtering, unusual-access cues, diagnostics rollups, and startup pruning of stale expired/revoked auth sessions
- Shared destructive-action confirmation and recovery messaging for admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse/form-editor, and attachment workflows
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance, support-bundle exports, and support-log viewing surfaced through the admin diagnostics workflow
## Shipped quality-of-life slices
- Purchase-order item entry restricted to purchasable inventory items only
- Inventory transfers between warehouses and locations
- Manual and work-order-driven inventory reservations
- Reserved and available stock visibility on inventory item detail and stock-by-location views
- Searchable operational pickers for customers, vendors, SKUs, BOM components, and other dense record selectors
- Route-level lazy loading and vendor chunking for a lighter initial client payload
- Persisted auth-session review filtering and admin-side access review cues
- Destructive-action confirmation coverage expanded into project customer/document unlinking and form-row removals in sales, purchasing, inventory, and warehouse editors
- Support-log filtering, retention cleanup, and richer filtered support-bundle exports in admin diagnostics
- Inventory thumbnail staging on item create/edit plus dedicated detail-page thumbnail display
- Demand-planning PO draft generation no longer applies mismatched sales-order line links to BOM child buy recommendations
- User-management saves now accept blank-password edits for existing users and surface errors in-page

View File

@@ -36,7 +36,7 @@
- Organize domain modules under `src/modules/<domain>`. - Organize domain modules under `src/modules/<domain>`.
- Keep HTTP routers thin; place business logic in services. - Keep HTTP routers thin; place business logic in services.
- Centralize Prisma access, auth middleware, file storage utilities, startup validation, and support logging in `src/lib`. - Centralize Prisma access, auth middleware, persisted session helpers, file storage utilities, startup validation, and support logging in `src/lib`.
- Store persistence-related constants under `src/config`. - Store persistence-related constants under `src/config`.
- Serve the built frontend from the API layer in production. - Serve the built frontend from the API layer in production.
@@ -52,3 +52,4 @@
3. Add permission keys in `shared/src/auth`. 3. Add permission keys in `shared/src/auth`.
4. Add frontend route/module under `client/src/modules/<domain>`. 4. Add frontend route/module under `client/src/modules/<domain>`.
5. Register navigation and route guards through the app shell without refactoring existing modules. 5. Register navigation and route guards through the app shell without refactoring existing modules.

View File

@@ -7,7 +7,7 @@
## Purpose ## Purpose
This guide explains how to deploy MRP Codex on an Unraid server using the Unraid Docker GUI rather than command-line Docker management. This guide explains how to deploy CODEXIUM on an Unraid server using the Unraid Docker GUI rather than command-line Docker management.
## What this container expects ## What this container expects
@@ -16,7 +16,7 @@ This guide explains how to deploy MRP Codex on an Unraid server using the Unraid
- Environment variables for the server port, JWT secret, and SQLite path - Environment variables for the server port, JWT secret, and SQLite path
- Automatic Prisma migration execution during container startup - Automatic Prisma migration execution during container startup
MRP Codex stores both the SQLite database and uploaded files inside the same persistent container path: CODEXIUM stores both the SQLite database and uploaded files inside the same persistent container path:
- database: `/app/data/prisma/app.db` - database: `/app/data/prisma/app.db`
- uploads: `/app/data/uploads` - uploads: `/app/data/uploads`
@@ -25,15 +25,15 @@ MRP Codex stores both the SQLite database and uploaded files inside the same per
- Build and push the image to a registry your Unraid server can access, or import it manually if you are self-hosting images - Build and push the image to a registry your Unraid server can access, or import it manually if you are self-hosting images
- Choose an Unraid share for persistent app data, for example: - Choose an Unraid share for persistent app data, for example:
- `/mnt/user/appdata/mrp-codex` - `/mnt/user/appdata/codexium`
- Choose the port you want to expose in Unraid, for example: - Choose the port you want to expose in Unraid, for example:
- host port `3000` - host port `3000`
- container port `3000` - container port `3000`
## Recommended Unraid paths ## Recommended Unraid paths
- App data share: `/mnt/user/appdata/mrp-codex` - App data share: `/mnt/user/appdata/codexium`
- Optional backup target: `/mnt/user/backups/mrp-codex` - Optional backup target: `/mnt/user/backups/codexium`
Keep the entire app data folder together so backups capture both the SQLite database and file attachments. Keep the entire app data folder together so backups capture both the SQLite database and file attachments.
@@ -106,7 +106,7 @@ If you do not set them, the defaults from the app bootstrapping logic are used.
On first container start, the entrypoint will: On first container start, the entrypoint will:
1. Ensure `/app/data/prisma` and `/app/data/uploads` exist 1. Ensure `/app/data/prisma` and `/app/data/uploads` exist
2. Run `npx prisma migrate deploy` 2. Run `/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma`
3. Start the Node.js server 3. Start the Node.js server
The frontend is served by the same container as the API, so there is only one exposed web port. The frontend is served by the same container as the API, so there is only one exposed web port.
@@ -128,9 +128,9 @@ When you publish a new image:
2. Apply the update from the Unraid GUI 2. Apply the update from the Unraid GUI
3. Start the container 3. Start the container
Because MRP Codex runs `prisma migrate deploy` during startup, committed migrations are applied automatically before the app launches. Because CODEXIUM runs `prisma migrate deploy` during startup, committed migrations are applied automatically before the app launches.
This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, and manufacturing work orders. Let the container complete startup migrations before testing new screens. This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, manufacturing work orders, audit tooling, and persisted auth sessions. Let the container complete startup migrations before testing new screens.
## Backup guidance ## Backup guidance
@@ -148,7 +148,7 @@ For consistent backups, stop the container before copying the appdata directory.
## Reverse proxy notes ## Reverse proxy notes
If you place MRP Codex behind Nginx Proxy Manager, Traefik, or another reverse proxy: If you place CODEXIUM behind Nginx Proxy Manager, Traefik, or another reverse proxy:
- keep the Unraid container on `bridge` or your preferred proxy-compatible network - keep the Unraid container on `bridge` or your preferred proxy-compatible network
- point the proxy at the Unraid host IP and chosen host port - point the proxy at the Unraid host IP and chosen host port
@@ -192,3 +192,4 @@ Set `CLIENT_ORIGIN` to the exact URL used by the browser, including protocol and
- Env: `DATABASE_URL=file:../../data/prisma/app.db` - Env: `DATABASE_URL=file:../../data/prisma/app.db`
- Env: `PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium` - Env: `PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium`
- Env: `CLIENT_ORIGIN=http://YOUR-UNRAID-IP:3000` - Env: `CLIENT_ORIGIN=http://YOUR-UNRAID-IP:3000`

View File

@@ -8,7 +8,7 @@ interface AuthContextValue {
user: AuthUser | null; user: AuthUser | null;
isReady: boolean; isReady: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => void; logout: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
@@ -48,13 +48,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(result.user); setUser(result.user);
window.localStorage.setItem(tokenKey, result.token); window.localStorage.setItem(tokenKey, result.token);
}, },
logout() { async logout() {
if (token) {
try {
await api.logout(token);
} catch {
// Clearing local auth state still signs the user out on the client.
}
}
window.localStorage.removeItem(tokenKey); window.localStorage.removeItem(tokenKey);
setToken(null); setToken(null);
setUser(null); setUser(null);
}, },
}), }),
[isReady, token, user] [token, user, isReady]
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -67,4 +74,3 @@ export function useAuth() {
} }
return context; return context;
} }

View File

@@ -14,10 +14,11 @@ const links = [
{ to: "/sales/quotes", label: "Quotes", icon: <QuoteIcon /> }, { to: "/sales/quotes", label: "Quotes", icon: <QuoteIcon /> },
{ to: "/sales/orders", label: "Sales Orders", icon: <SalesOrderIcon /> }, { to: "/sales/orders", label: "Sales Orders", icon: <SalesOrderIcon /> },
{ to: "/purchasing/orders", label: "Purchase Orders", icon: <PurchaseOrderIcon /> }, { to: "/purchasing/orders", label: "Purchase Orders", icon: <PurchaseOrderIcon /> },
{ to: "/finance", label: "Finance", icon: <FinanceIcon /> },
{ to: "/shipping/shipments", label: "Shipments", icon: <ShipmentIcon /> }, { to: "/shipping/shipments", label: "Shipments", icon: <ShipmentIcon /> },
{ to: "/projects", label: "Projects", icon: <ProjectsIcon /> }, { to: "/projects", label: "Projects", icon: <ProjectsIcon /> },
{ to: "/manufacturing/work-orders", label: "Manufacturing", icon: <ManufacturingIcon /> }, { to: "/manufacturing/work-orders", label: "Manufacturing", icon: <ManufacturingIcon /> },
{ to: "/planning/gantt", label: "Gantt", icon: <GanttIcon /> }, { to: "/planning/workbench", label: "Workbench", icon: <WorkbenchIcon /> },
]; ];
function NavIcon({ children }: { children: ReactNode }) { function NavIcon({ children }: { children: ReactNode }) {
@@ -146,7 +147,19 @@ function ShipmentIcon() {
); );
} }
function GanttIcon() { function FinanceIcon() {
return (
<NavIcon>
<path d="M4 18h16" />
<path d="M7 15V9" />
<path d="M12 15V6" />
<path d="M17 15v-4" />
<path d="M5 6h14" />
</NavIcon>
);
}
function WorkbenchIcon() {
return ( return (
<NavIcon> <NavIcon>
<path d="M4 6h5" /> <path d="M4 6h5" />
@@ -187,21 +200,19 @@ export function AppShell() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
return ( return (
<div className="min-h-screen px-4 py-5 xl:px-6 2xl:px-8"> <div className="min-h-screen px-3 py-3 xl:px-4 2xl:px-5">
<div className="mx-auto flex w-full max-w-[1760px] gap-3 2xl:gap-4"> <div className="mx-auto flex w-full max-w-[1760px] gap-2.5 2xl:gap-3">
<aside className="hidden w-72 shrink-0 flex-col rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur md:flex 2xl:w-80"> <aside className="hidden w-72 shrink-0 flex-col rounded-[20px] border border-line/70 bg-surface/90 p-3 shadow-panel backdrop-blur md:flex 2xl:w-80">
<div> <div>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">MRP Codex</div> <h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1>
<h1 className="mt-2 text-xl font-extrabold text-text">Manufacturing foundation</h1>
<p className="mt-2 text-sm text-muted">Single-tenant platform shell with branding, auth, file storage, and planning foundations.</p>
</div> </div>
<nav className="mt-6 space-y-2"> <nav className="mt-4 space-y-1.5">
{links.map((link) => ( {links.map((link) => (
<NavLink <NavLink
key={link.to} key={link.to}
to={link.to} to={link.to}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2 rounded-2xl px-2 py-2 text-sm font-semibold transition ${ `flex items-center gap-2 rounded-xl px-2.5 py-2 text-[12px] font-semibold uppercase tracking-[0.12em] transition ${
isActive ? "bg-brand text-white" : "text-text hover:bg-page" isActive ? "bg-brand text-white" : "text-text hover:bg-page"
}` }`
} }
@@ -211,33 +222,34 @@ export function AppShell() {
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="mt-auto rounded-2xl border border-line/70 bg-page/70 p-4"> <div className="mt-auto space-y-2.5">
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p> <div className="rounded-[16px] border border-line/70 bg-page/70 p-2.5">
<p className="text-xs text-muted">{user?.email}</p> <p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">Theme</p>
<button <ThemeToggle />
type="button" </div>
onClick={logout} <div className="rounded-[16px] border border-line/70 bg-page/70 p-3">
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page" <p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
> <p className="text-xs text-muted">{user?.email}</p>
Sign out <button
</button> type="button"
onClick={() => {
void logout();
}}
className="mt-3 rounded-xl bg-text px-3 py-2 text-sm font-semibold text-page"
>
Sign out
</button>
</div>
</div> </div>
</aside> </aside>
<main className="min-w-0 flex-1"> <main className="min-w-0 flex-1">
<div className="mb-4 flex items-center justify-between rounded-[28px] border border-line/70 bg-surface/90 px-2 py-2 shadow-panel backdrop-blur 2xl:px-3"> <nav className="mb-3 flex gap-2 overflow-x-auto rounded-[18px] border border-line/70 bg-surface/85 p-2.5 shadow-panel backdrop-blur md:hidden">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Operations Command</p>
<h2 className="text-lg font-bold text-text">Foundation Console</h2>
</div>
<ThemeToggle />
</div>
<nav className="mb-4 flex gap-3 overflow-x-auto rounded-[24px] border border-line/70 bg-surface/85 p-3 shadow-panel backdrop-blur md:hidden">
{links.map((link) => ( {links.map((link) => (
<NavLink <NavLink
key={link.to} key={link.to}
to={link.to} to={link.to}
className={({ isActive }) => className={({ isActive }) =>
`inline-flex whitespace-nowrap items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition ${ `inline-flex whitespace-nowrap items-center gap-2 rounded-xl px-3 py-2 text-[12px] font-semibold uppercase tracking-[0.12em] transition ${
isActive ? "bg-brand text-white" : "bg-page/70 text-text" isActive ? "bg-brand text-white" : "bg-page/70 text-text"
}` }`
} }
@@ -247,6 +259,9 @@ export function AppShell() {
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="mb-3 md:hidden">
<ThemeToggle />
</div>
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
interface ConfirmActionDialogProps {
open: boolean;
title: string;
description: string;
impact?: string;
recovery?: string;
confirmLabel?: string;
cancelLabel?: string;
intent?: "danger" | "primary";
confirmationLabel?: string;
confirmationValue?: string;
extraFieldLabel?: string;
extraFieldPlaceholder?: string;
extraFieldValue?: string;
extraFieldRequired?: boolean;
extraFieldMultiline?: boolean;
onExtraFieldChange?: (value: string) => void;
isConfirming?: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
export function ConfirmActionDialog({
open,
title,
description,
impact,
recovery,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
intent = "danger",
confirmationLabel,
confirmationValue,
extraFieldLabel,
extraFieldPlaceholder,
extraFieldValue = "",
extraFieldRequired = false,
extraFieldMultiline = false,
onExtraFieldChange,
isConfirming = false,
onConfirm,
onClose,
}: ConfirmActionDialogProps) {
const [typedValue, setTypedValue] = useState("");
useEffect(() => {
if (open) {
setTypedValue("");
}
}, [open]);
if (!open) {
return null;
}
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
const requiresExtraField = Boolean(extraFieldLabel);
const isConfirmDisabled =
isConfirming ||
(requiresTypedConfirmation && typedValue.trim() !== confirmationValue) ||
(requiresExtraField && extraFieldRequired && extraFieldValue.trim().length === 0);
const confirmButtonClass =
intent === "danger"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-brand text-white hover:brightness-110";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
<div className="w-full max-w-xl rounded-[20px] border border-line/70 bg-surface p-5 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
{impact ? (
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
<span className="mt-1 block">{impact}</span>
</div>
) : null}
{recovery ? (
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
<span className="mt-1 block">{recovery}</span>
</div>
) : null}
{requiresTypedConfirmation ? (
<label className="mt-4 block">
<span className="mb-2 block text-sm font-semibold text-text">
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
</span>
<input
value={typedValue}
onChange={(event) => setTypedValue(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
autoFocus
/>
</label>
) : null}
{requiresExtraField ? (
<label className="mt-4 block">
<span className="mb-2 block text-sm font-semibold text-text">{extraFieldLabel}</span>
{extraFieldMultiline ? (
<textarea
value={extraFieldValue}
onChange={(event) => onExtraFieldChange?.(event.target.value)}
placeholder={extraFieldPlaceholder}
rows={4}
className="w-full rounded-[18px] border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
) : (
<input
value={extraFieldValue}
onChange={(event) => onExtraFieldChange?.(event.target.value)}
placeholder={extraFieldPlaceholder}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
)}
</label>
) : null}
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
disabled={isConfirming}
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => {
void onConfirm();
}}
disabled={isConfirmDisabled}
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
>
{isConfirming ? "Working..." : confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
type RevisionOption = {
id: string;
label: string;
meta: string;
};
type ComparisonField = {
label: string;
value: string;
};
type ComparisonLine = {
key: string;
title: string;
subtitle?: string;
quantity: string;
unitLabel: string;
amountLabel: string;
totalLabel?: string;
extraLabel?: string;
};
type ComparisonDocument = {
title: string;
subtitle: string;
status: string;
metaFields: ComparisonField[];
totalFields: ComparisonField[];
notes: string;
lines: ComparisonLine[];
};
type DiffRow = {
key: string;
status: "ADDED" | "REMOVED" | "CHANGED";
left?: ComparisonLine;
right?: ComparisonLine;
};
function buildLineMap(lines: ComparisonLine[]) {
return new Map(lines.map((line) => [line.key, line]));
}
function lineSignature(line?: ComparisonLine) {
if (!line) {
return "";
}
return [line.title, line.subtitle ?? "", line.quantity, line.unitLabel, line.amountLabel, line.totalLabel ?? "", line.extraLabel ?? ""].join("|");
}
function buildDiffRows(left: ComparisonDocument, right: ComparisonDocument): DiffRow[] {
const leftLines = buildLineMap(left.lines);
const rightLines = buildLineMap(right.lines);
const orderedKeys = [...new Set([...left.lines.map((line) => line.key), ...right.lines.map((line) => line.key)])];
const rows: DiffRow[] = [];
for (const key of orderedKeys) {
const leftLine = leftLines.get(key);
const rightLine = rightLines.get(key);
if (leftLine && !rightLine) {
rows.push({ key, status: "REMOVED", left: leftLine });
continue;
}
if (!leftLine && rightLine) {
rows.push({ key, status: "ADDED", right: rightLine });
continue;
}
if (lineSignature(leftLine) !== lineSignature(rightLine)) {
rows.push({ key, status: "CHANGED", left: leftLine, right: rightLine });
}
}
return rows;
}
function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): Array<{ label: string; leftValue: string; rightValue: string }> {
const rightByLabel = new Map(right.map((field) => [field.label, field.value]));
return left.flatMap((field) => {
const rightValue = rightByLabel.get(field.label);
if (rightValue == null || rightValue === field.value) {
return [];
}
return [
{
label: field.label,
leftValue: field.value,
rightValue,
},
];
});
}
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
return (
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
<h4 className="mt-2 text-base font-bold text-text">{document.title}</h4>
<p className="mt-1 text-sm text-muted">{document.subtitle}</p>
</div>
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
{document.status}
</span>
</div>
<dl className="mt-3 grid gap-2 sm:grid-cols-2">
{document.metaFields.map((field) => (
<div key={`${label}-${field.label}`}>
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
<dd className="mt-1 text-sm text-text">{field.value}</dd>
</div>
))}
</dl>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{document.totalFields.map((field) => (
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
</div>
))}
</div>
<div className="mt-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
</div>
</article>
);
}
export function DocumentRevisionComparison({
title,
description,
currentLabel,
currentDocument,
revisions,
getRevisionDocument,
}: {
title: string;
description: string;
currentLabel: string;
currentDocument: ComparisonDocument;
revisions: RevisionOption[];
getRevisionDocument: (revisionId: string | "current") => ComparisonDocument;
}) {
const [leftRevisionId, setLeftRevisionId] = useState<string | "current">(revisions[0]?.id ?? "current");
const [rightRevisionId, setRightRevisionId] = useState<string | "current">("current");
useEffect(() => {
setLeftRevisionId((current) => (current === "current" || revisions.some((revision) => revision.id === current) ? current : revisions[0]?.id ?? "current"));
}, [revisions]);
const leftDocument = getRevisionDocument(leftRevisionId);
const rightDocument = getRevisionDocument(rightRevisionId);
const diffRows = buildDiffRows(leftDocument, rightDocument);
const metaChanges = buildFieldChanges(leftDocument.metaFields, rightDocument.metaFields);
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
return (
<section className="surface-panel">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="section-kicker">{title}</p>
<p className="mt-1 text-sm text-muted">{description}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block min-w-[220px]">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</span>
<select
value={leftRevisionId}
onChange={(event) => setLeftRevisionId(event.target.value as string | "current")}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
>
{revisions.map((revision) => (
<option key={revision.id} value={revision.id}>
{revision.label} | {revision.meta}
</option>
))}
</select>
</label>
<label className="block min-w-[220px]">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</span>
<select
value={rightRevisionId}
onChange={(event) => setRightRevisionId(event.target.value as string | "current")}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
>
<option value="current">{currentLabel}</option>
{revisions.map((revision) => (
<option key={revision.id} value={revision.id}>
{revision.label} | {revision.meta}
</option>
))}
</select>
</label>
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
<ComparisonCard label="Baseline" document={leftDocument} />
<ComparisonCard label="Compare To" document={rightDocument} />
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
{metaChanges.length === 0 && totalChanges.length === 0 ? (
<div className="mt-3 text-sm text-muted">No header or total changes.</div>
) : (
<div className="mt-3 space-y-2">
{[...metaChanges, ...totalChanges].map((change) => (
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
<div className="mt-2 text-sm text-text">
{change.leftValue} {"->"} {change.rightValue}
</div>
</div>
))}
</div>
)}
</article>
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
</div>
</div>
{diffRows.length === 0 ? (
<div className="mt-3 text-sm text-muted">No line-level changes.</div>
) : (
<div className="mt-3 space-y-2">
{diffRows.map((row) => (
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
</div>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</div>
<div className="mt-1 text-sm text-text">
{row.left ? `${row.left.quantity} | ${row.left.amountLabel}${row.left.totalLabel ? ` | ${row.left.totalLabel}` : ""}` : "Not present"}
</div>
{row.left?.subtitle ? <div className="mt-1 text-xs text-muted">{row.left.subtitle}</div> : null}
{row.left?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.left.extraLabel}</div> : null}
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</div>
<div className="mt-1 text-sm text-text">
{row.right ? `${row.right.quantity} | ${row.right.amountLabel}${row.right.totalLabel ? ` | ${row.right.totalLabel}` : ""}` : "Not present"}
</div>
{row.right?.subtitle ? <div className="mt-1 text-xs text-muted">{row.right.subtitle}</div> : null}
{row.right?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.right.extraLabel}</div> : null}
</div>
</div>
</div>
))}
</div>
)}
</article>
</div>
</section>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useAuth } from "../auth/AuthProvider"; import { useAuth } from "../auth/AuthProvider";
import { api, ApiError } from "../lib/api"; import { api, ApiError } from "../lib/api";
import { ConfirmActionDialog } from "./ConfirmActionDialog";
interface FileAttachmentsPanelProps { interface FileAttachmentsPanelProps {
ownerType: string; ownerType: string;
@@ -41,6 +42,7 @@ export function FileAttachmentsPanel({
const [status, setStatus] = useState("Loading attachments..."); const [status, setStatus] = useState("Loading attachments...");
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null); const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false; const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
@@ -120,22 +122,23 @@ export function FileAttachmentsPanel({
onAttachmentCountChange?.(nextAttachments.length); onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments; return nextAttachments;
}); });
setStatus("Attachment deleted."); setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to delete attachment."; const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
setStatus(message); setStatus(message);
} finally { } finally {
setDeletingAttachmentId(null); setDeletingAttachmentId(null);
setAttachmentPendingDelete(null);
} }
} }
return ( return (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel min-w-0">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p> <p className="section-kicker">{eyebrow}</p>
<h4 className="mt-2 text-lg font-bold text-text">{title}</h4> <h4 className="text-lg font-bold text-text">{title}</h4>
<p className="mt-2 text-sm text-muted">{description}</p> <p className="mt-1 text-sm text-muted">{description}</p>
</div> </div>
{canWriteFiles ? ( {canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"> <label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
@@ -144,26 +147,26 @@ export function FileAttachmentsPanel({
</label> </label>
) : null} ) : null}
</div> </div>
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
{!canReadFiles ? ( {!canReadFiles ? (
<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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
You do not have permission to view file attachments. You do not have permission to view file attachments.
</div> </div>
) : attachments.length === 0 ? ( ) : attachments.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
{emptyMessage} {emptyMessage}
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{attachments.map((attachment) => ( {attachments.map((attachment) => (
<div <div
key={attachment.id} key={attachment.id}
className="flex flex-col gap-2 rounded-3xl border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between" className="flex flex-col gap-2 rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p> <p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">
{attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()} {attachment.mimeType} - {formatFileSize(attachment.sizeBytes)} - {new Date(attachment.createdAt).toLocaleString()}
</p> </p>
</div> </div>
<div className="flex shrink-0 gap-3"> <div className="flex shrink-0 gap-3">
@@ -177,7 +180,7 @@ export function FileAttachmentsPanel({
{canWriteFiles ? ( {canWriteFiles ? (
<button <button
type="button" type="button"
onClick={() => handleDelete(attachment)} onClick={() => setAttachmentPendingDelete(attachment)}
disabled={deletingAttachmentId === attachment.id} disabled={deletingAttachmentId === attachment.id}
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300" className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
> >
@@ -189,6 +192,30 @@ export function FileAttachmentsPanel({
))} ))}
</div> </div>
)} )}
<ConfirmActionDialog
open={attachmentPendingDelete != null}
title="Delete attachment"
description={
attachmentPendingDelete
? `Delete ${attachmentPendingDelete.originalName} from this record.`
: "Delete this attachment."
}
impact="The file link will be removed from this record immediately."
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
confirmLabel="Delete file"
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
onClose={() => {
if (!deletingAttachmentId) {
setAttachmentPendingDelete(null);
}
}}
onConfirm={async () => {
if (attachmentPendingDelete) {
await handleDelete(attachmentPendingDelete);
}
}}
/>
</article> </article>
); );
} }

View File

@@ -7,10 +7,9 @@ export function ThemeToggle() {
<button <button
type="button" type="button"
onClick={toggleMode} onClick={toggleMode}
className="rounded-full border border-line/70 bg-surface px-4 py-2 text-sm font-semibold text-text transition hover:border-brand/60" className="w-full rounded-xl border border-line/70 bg-page/70 px-3 py-2 text-sm font-semibold text-text transition hover:border-brand/60"
> >
{mode === "light" ? "Dark mode" : "Light mode"} {mode === "light" ? "Dark mode" : "Light mode"}
</button> </button>
); );
} }

View File

@@ -1,9 +1,39 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer components {
.page-stack {
@apply space-y-3;
}
.surface-panel {
@apply rounded-[18px] border border-line/70 bg-surface/90 p-3 shadow-panel;
}
.surface-panel-tight {
@apply rounded-[16px] border border-line/70 bg-page/60 px-3 py-2.5;
}
.section-kicker {
@apply text-[11px] font-semibold uppercase tracking-[0.24em] text-muted;
}
.metric-kicker {
@apply text-[11px] font-semibold uppercase tracking-[0.18em] text-muted;
}
.module-title {
@apply mt-1 text-xl font-bold uppercase tracking-[0.08em] text-text;
}
.planner-sticky-bar {
@apply sticky top-3 z-20 rounded-[18px] border border-line/70 bg-surface/90 p-3 shadow-panel backdrop-blur;
}
}
:root { :root {
color-scheme: light; color-scheme: light;
--font-family: "Manrope"; --font-family: "Manrope";

View File

@@ -1,20 +1,31 @@
import type { import type {
AdminDiagnosticsDto, AdminDiagnosticsDto,
AdminAuthSessionDto,
BackupGuidanceDto, BackupGuidanceDto,
AdminPermissionOptionDto, AdminPermissionOptionDto,
AdminRoleDto, AdminRoleDto,
AdminRoleInput, AdminRoleInput,
SupportLogEntryDto, SupportLogEntryDto,
SupportLogFiltersDto,
SupportLogListDto,
SupportSnapshotDto, SupportSnapshotDto,
AdminUserDto, AdminUserDto,
AdminUserInput, AdminUserInput,
ApiResponse, ApiResponse,
CompanyProfileDto, CompanyProfileDto,
CompanyProfileInput, CompanyProfileInput,
FinanceCapexDto,
FinanceCapexInput,
FinanceCustomerPaymentDto,
FinanceCustomerPaymentInput,
FinanceDashboardDto,
FinanceProfileDto,
FinanceProfileInput,
FileAttachmentDto, FileAttachmentDto,
PlanningTimelineDto, PlanningTimelineDto,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
LogoutResponse,
} from "@mrp/shared"; } from "@mrp/shared";
import type { import type {
CrmContactDto, CrmContactDto,
@@ -32,6 +43,12 @@ import type {
InventoryItemDetailDto, InventoryItemDetailDto,
InventoryItemInput, InventoryItemInput,
InventoryItemOptionDto, InventoryItemOptionDto,
InventorySkuBuilderPreviewDto,
InventorySkuCatalogTreeDto,
InventorySkuFamilyDto,
InventorySkuFamilyInput,
InventorySkuNodeDto,
InventorySkuNodeInput,
InventoryReservationInput, InventoryReservationInput,
InventoryItemStatus, InventoryItemStatus,
InventoryItemSummaryDto, InventoryItemSummaryDto,
@@ -51,15 +68,23 @@ import type {
WorkOrderCompletionInput, WorkOrderCompletionInput,
WorkOrderDetailDto, WorkOrderDetailDto,
WorkOrderInput, WorkOrderInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderMaterialIssueInput, WorkOrderMaterialIssueInput,
WorkOrderStatus, WorkOrderStatus,
WorkOrderStatusUpdateInput,
WorkOrderSummaryDto, WorkOrderSummaryDto,
ManufacturingUserOptionDto,
} from "@mrp/shared"; } from "@mrp/shared";
import type { import type {
ProjectCustomerOptionDto, ProjectCustomerOptionDto,
ProjectDetailDto, ProjectDetailDto,
ProjectDocumentOptionDto, ProjectDocumentOptionDto,
ProjectInput, ProjectInput,
ProjectMilestoneStatusUpdateInput,
ProjectOwnerOptionDto, ProjectOwnerOptionDto,
ProjectPriority, ProjectPriority,
ProjectShipmentOptionDto, ProjectShipmentOptionDto,
@@ -79,6 +104,7 @@ import type {
import type { import type {
PurchaseOrderDetailDto, PurchaseOrderDetailDto,
PurchaseOrderInput, PurchaseOrderInput,
PurchaseOrderRevisionDto,
PurchaseOrderStatus, PurchaseOrderStatus,
PurchaseOrderSummaryDto, PurchaseOrderSummaryDto,
PurchaseVendorOptionDto, PurchaseVendorOptionDto,
@@ -88,6 +114,7 @@ import type {
ShipmentDetailDto, ShipmentDetailDto,
ShipmentInput, ShipmentInput,
ShipmentOrderOptionDto, ShipmentOrderOptionDto,
ShipmentPickInput,
ShipmentStatus, ShipmentStatus,
ShipmentSummaryDto, ShipmentSummaryDto,
} from "@mrp/shared/dist/shipping/types.js"; } from "@mrp/shared/dist/shipping/types.js";
@@ -138,6 +165,9 @@ export const api = {
me(token: string) { me(token: string) {
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token); return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
}, },
logout(token: string) {
return request<LogoutResponse>("/api/v1/auth/logout", { method: "POST" }, token);
},
getAdminDiagnostics(token: string) { getAdminDiagnostics(token: string) {
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token); return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
}, },
@@ -147,8 +177,33 @@ export const api = {
getSupportSnapshot(token: string) { getSupportSnapshot(token: string) {
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token); return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
}, },
getSupportLogs(token: string) { getSupportSnapshotWithFilters(token: string, filters?: SupportLogFiltersDto) {
return request<SupportLogEntryDto[]>("/api/v1/admin/support-logs", undefined, token); return request<SupportSnapshotDto>(
`/api/v1/admin/support-snapshot${buildQueryString({
level: filters?.level,
source: filters?.source,
query: filters?.query,
start: filters?.start,
end: filters?.end,
limit: filters?.limit?.toString(),
})}`,
undefined,
token
);
},
getSupportLogs(token: string, filters?: SupportLogFiltersDto) {
return request<SupportLogListDto>(
`/api/v1/admin/support-logs${buildQueryString({
level: filters?.level,
source: filters?.source,
query: filters?.query,
start: filters?.start,
end: filters?.end,
limit: filters?.limit?.toString(),
})}`,
undefined,
token
);
}, },
getAdminPermissions(token: string) { getAdminPermissions(token: string) {
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token); return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
@@ -165,6 +220,12 @@ export const api = {
getAdminUsers(token: string) { getAdminUsers(token: string) {
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token); return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
}, },
getAdminSessions(token: string) {
return request<AdminAuthSessionDto[]>("/api/v1/admin/sessions", undefined, token);
},
revokeAdminSession(token: string, sessionId: string) {
return request<LogoutResponse>(`/api/v1/admin/sessions/${sessionId}/revoke`, { method: "POST" }, token);
},
createAdminUser(token: string, payload: AdminUserInput) { createAdminUser(token: string, payload: AdminUserInput) {
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token); return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
}, },
@@ -235,6 +296,21 @@ export const api = {
token token
); );
}, },
getFinanceDashboard(token: string) {
return request<FinanceDashboardDto>("/api/v1/finance/overview", undefined, token);
},
updateFinanceProfile(token: string, payload: FinanceProfileInput) {
return request<FinanceProfileDto>("/api/v1/finance/profile", { method: "PUT", body: JSON.stringify(payload) }, token);
},
createFinancePayment(token: string, payload: FinanceCustomerPaymentInput) {
return request<FinanceCustomerPaymentDto>("/api/v1/finance/payments", { method: "POST", body: JSON.stringify(payload) }, token);
},
createCapexEntry(token: string, payload: FinanceCapexInput) {
return request<FinanceCapexDto>("/api/v1/finance/capex", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateCapexEntry(token: string, capexId: string, payload: FinanceCapexInput) {
return request<FinanceCapexDto>(`/api/v1/finance/capex/${capexId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getCustomers( getCustomers(
token: string, token: string,
filters?: { filters?: {
@@ -391,6 +467,38 @@ export const api = {
getInventoryItemOptions(token: string) { getInventoryItemOptions(token: string) {
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token); return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
}, },
getInventorySkuFamilies(token: string) {
return request<InventorySkuFamilyDto[]>("/api/v1/inventory/sku/families", undefined, token);
},
getInventorySkuCatalog(token: string) {
return request<InventorySkuCatalogTreeDto>("/api/v1/inventory/sku/catalog", undefined, token);
},
getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) {
return request<InventorySkuNodeDto[]>(
`/api/v1/inventory/sku/nodes${buildQueryString({
familyId,
parentNodeId: parentNodeId ?? undefined,
})}`,
undefined,
token
);
},
getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) {
return request<InventorySkuBuilderPreviewDto>(
`/api/v1/inventory/sku/preview${buildQueryString({
familyId,
nodeId: nodeId ?? undefined,
})}`,
undefined,
token
);
},
createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) {
return request<InventorySkuFamilyDto>("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token);
},
createInventorySkuNode(token: string, payload: InventorySkuNodeInput) {
return request<InventorySkuNodeDto>("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token);
},
getWarehouseLocationOptions(token: string) { getWarehouseLocationOptions(token: string) {
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token); return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
}, },
@@ -495,6 +603,13 @@ export const api = {
updateProject(token: string, projectId: string, payload: ProjectInput) { updateProject(token: string, projectId: string, payload: ProjectInput) {
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token); return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
}, },
updateProjectMilestoneStatus(token: string, projectId: string, milestoneId: string, payload: ProjectMilestoneStatusUpdateInput) {
return request<ProjectDetailDto>(
`/api/v1/projects/${projectId}/milestones/${milestoneId}/status`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
getProjectCustomerOptions(token: string) { getProjectCustomerOptions(token: string) {
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token); return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
}, },
@@ -528,12 +643,18 @@ export const api = {
getManufacturingProjectOptions(token: string) { getManufacturingProjectOptions(token: string) {
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token); return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
}, },
getManufacturingUserOptions(token: string) {
return request<ManufacturingUserOptionDto[]>("/api/v1/manufacturing/users/options", undefined, token);
},
getManufacturingStations(token: string) { getManufacturingStations(token: string) {
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token); return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
}, },
createManufacturingStation(token: string, payload: ManufacturingStationInput) { createManufacturingStation(token: string, payload: ManufacturingStationInput) {
return request<ManufacturingStationDto>("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token); return request<ManufacturingStationDto>("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token);
}, },
updateManufacturingStation(token: string, stationId: string, payload: ManufacturingStationInput) {
return request<ManufacturingStationDto>(`/api/v1/manufacturing/stations/${stationId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) { getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
return request<WorkOrderSummaryDto[]>( return request<WorkOrderSummaryDto[]>(
`/api/v1/manufacturing/work-orders${buildQueryString({ `/api/v1/manufacturing/work-orders${buildQueryString({
@@ -555,10 +676,45 @@ export const api = {
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) { updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token); return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
}, },
updateWorkOrderStatus(token: string, workOrderId: string, status: WorkOrderStatus) { updateWorkOrderStatus(token: string, workOrderId: string, payload: WorkOrderStatusUpdateInput) {
return request<WorkOrderDetailDto>( return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/status`, `/api/v1/manufacturing/work-orders/${workOrderId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) }, { method: "PATCH", body: JSON.stringify(payload) },
token
);
},
updateWorkOrderOperationSchedule(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationScheduleInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/schedule`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
updateWorkOrderOperationExecution(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationExecutionInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/execution`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
recordWorkOrderOperationLabor(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationLaborEntryInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/labor`,
{ method: "POST", body: JSON.stringify(payload) },
token
);
},
updateWorkOrderOperationAssignment(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationAssignmentInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/assignment`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
updateWorkOrderOperationTimer(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationTimerInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/timer`,
{ method: "PATCH", body: JSON.stringify(payload) },
token token
); );
}, },
@@ -672,6 +828,9 @@ export const api = {
getPurchaseOrder(token: string, orderId: string) { getPurchaseOrder(token: string, orderId: string) {
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, undefined, token); return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, undefined, token);
}, },
getPurchaseOrderRevisions(token: string, orderId: string) {
return request<PurchaseOrderRevisionDto[]>(`/api/v1/purchasing/orders/${orderId}/revisions`, undefined, token);
},
createPurchaseOrder(token: string, payload: PurchaseOrderInput) { createPurchaseOrder(token: string, payload: PurchaseOrderInput) {
return request<PurchaseOrderDetailDto>("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token); return request<PurchaseOrderDetailDto>("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token);
}, },
@@ -722,6 +881,9 @@ export const api = {
token token
); );
}, },
postShipmentPick(token: string, shipmentId: string, payload: ShipmentPickInput) {
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}/picks`, { method: "POST", body: JSON.stringify(payload) }, token);
},
async getShipmentPackingSlipPdf(token: string, shipmentId: string) { async getShipmentPackingSlipPdf(token: string, shipmentId: string) {
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/packing-slip.pdf`, { const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/packing-slip.pdf`, {
headers: { headers: {

View File

@@ -44,6 +44,9 @@ const InventoryDetailPage = React.lazy(() =>
const InventoryFormPage = React.lazy(() => const InventoryFormPage = React.lazy(() =>
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage })) import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
); );
const InventorySkuMasterPage = React.lazy(() =>
import("./modules/inventory/InventorySkuMasterPage").then((module) => ({ default: module.InventorySkuMasterPage }))
);
const WarehousesPage = React.lazy(() => const WarehousesPage = React.lazy(() =>
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage })) import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
); );
@@ -98,13 +101,22 @@ const ShipmentDetailPage = React.lazy(() =>
const ShipmentFormPage = React.lazy(() => const ShipmentFormPage = React.lazy(() =>
import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage })) import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage }))
); );
const GanttPage = React.lazy(() => const FinancePage = React.lazy(() =>
import("./modules/gantt/GanttPage").then((module) => ({ default: module.GanttPage })) import("./modules/finance/FinancePage").then((module) => ({ default: module.FinancePage }))
);
const WorkbenchPage = React.lazy(() =>
import("./modules/workbench/WorkbenchPage").then((module) => ({ default: module.WorkbenchPage }))
);
const LandingPage = React.lazy(() =>
import("./modules/landing/LandingPage").then((module) => ({ default: module.LandingPage }))
);
const DarkLandingPage = React.lazy(() =>
import("./modules/landing/LandingPage").then((module) => ({ default: module.DarkLandingPage }))
); );
function RouteFallback() { function RouteFallback() {
return ( return (
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel"> <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
Loading module... Loading module...
</div> </div>
); );
@@ -116,6 +128,8 @@ function lazyElement(element: React.ReactNode) {
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> }, { path: "/login", element: <LoginPage /> },
{ path: "/landing", element: lazyElement(<LandingPage />) },
{ path: "/darklanding", element: lazyElement(<DarkLandingPage />) },
{ {
element: <ProtectedRoute />, element: <ProtectedRoute />,
children: [ children: [
@@ -148,6 +162,7 @@ const router = createBrowserRouter([
children: [ children: [
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) }, { path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) }, { path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
{ path: "/inventory/sku-master", element: lazyElement(<InventorySkuMasterPage />) },
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) }, { path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) }, { path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
], ],
@@ -189,6 +204,10 @@ const router = createBrowserRouter([
{ path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) }, { path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) },
], ],
}, },
{
element: <ProtectedRoute requiredPermissions={[permissions.financeRead]} />,
children: [{ path: "/finance", element: lazyElement(<FinancePage />) }],
},
{ {
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />, element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [ children: [
@@ -246,7 +265,10 @@ const router = createBrowserRouter([
}, },
{ {
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
children: [{ path: "/planning/gantt", element: lazyElement(<GanttPage />) }], children: [
{ path: "/planning/workbench", element: lazyElement(<WorkbenchPage />) },
{ path: "/planning/gantt", element: <Navigate to="/planning/workbench" replace /> },
],
}, },
], ],
}, },
@@ -266,3 +288,4 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -58,21 +58,20 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
} }
return ( return (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p> <p className="section-kicker">CONTACTS</p>
<h4 className="mt-2 text-lg font-bold text-text">People on this account</h4> <div className="mt-3 space-y-2">
<div className="mt-5 space-y-3">
{contacts.length === 0 ? ( {contacts.length === 0 ? (
<div className="rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted"> <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No contacts have been added yet. No contacts have been added yet.
</div> </div>
) : ( ) : (
contacts.map((contact) => ( contacts.map((contact) => (
<div key={contact.id} className="rounded-3xl border border-line/70 bg-page/60 px-2 py-2"> <div key={contact.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<div className="text-sm font-semibold text-text"> <div className="text-sm font-semibold text-text">
{contact.fullName} {contact.isPrimary ? <span className="text-brand"> Primary</span> : null} {contact.fullName} {contact.isPrimary ? <span className="text-brand">- PRIMARY</span> : null}
</div> </div>
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div> <div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
</div> </div>
@@ -86,22 +85,22 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
)} )}
</div> </div>
{canManage ? ( {canManage ? (
<form className="mt-5 space-y-4" onSubmit={handleSubmit}> <form className="mt-3 space-y-3" onSubmit={handleSubmit}>
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Full name</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Full name</span>
<input <input
value={form.fullName} value={form.fullName}
onChange={(event) => updateField("fullName", event.target.value)} onChange={(event) => updateField("fullName", 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" 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>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Role</span>
<select <select
value={form.role} value={form.role}
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])} onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
> >
{crmContactRoleOptions.map((option) => ( {crmContactRoleOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
@@ -111,24 +110,24 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
</select> </select>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Email</span>
<input <input
type="email" type="email"
value={form.email} value={form.email}
onChange={(event) => updateField("email", event.target.value)} onChange={(event) => updateField("email", 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" 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>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Phone</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Phone</span>
<input <input
value={form.phone} value={form.phone}
onChange={(event) => updateField("phone", event.target.value)} onChange={(event) => updateField("phone", 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" 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>
</div> </div>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2"> <label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input <input
type="checkbox" type="checkbox"
checked={form.isPrimary} checked={form.isPrimary}
@@ -136,12 +135,12 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
/> />
<span className="text-sm font-semibold text-text">Primary contact</span> <span className="text-sm font-semibold text-text">Primary contact</span>
</label> </label>
<div className="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"> <div className="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="text-sm text-muted">{status}</span> <span className="text-sm text-muted">{status}</span>
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60" className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
> >
{isSaving ? "Saving..." : "Add contact"} {isSaving ? "Saving..." : "Add contact"}
</button> </button>

View File

@@ -58,7 +58,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
}, [config.singularLabel, entity, recordId, token]); }, [config.singularLabel, entity, recordId, token]);
if (!record) { if (!record) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
function updateContactEntryField<Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) { function updateContactEntryField<Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) {
@@ -111,21 +111,19 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p> <p className="section-kicker">CRM DETAIL</p>
<h3 className="mt-2 text-2xl font-bold text-text">{record.name}</h3> <h3 className="module-title">{record.name}</h3>
<div className="mt-4"> <div className="mt-2.5">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-2">
<CrmStatusBadge status={record.status} /> <CrmStatusBadge status={record.status} />
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null} {record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
</div> </div>
</div> </div>
<p className="mt-2 text-sm text-muted"> <p className="mt-2 text-sm text-muted">UPDATED {new Date(record.updatedAt).toLocaleString()}</p>
{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}.
</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link <Link
@@ -146,8 +144,8 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div> </div>
</div> </div>
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]"> <div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact</p> <p className="section-kicker">CONTACT</p>
<dl className="mt-5 grid gap-3 xl:grid-cols-2"> <dl className="mt-5 grid gap-3 xl:grid-cols-2">
<div> <div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
@@ -176,8 +174,8 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div> </div>
</dl> </dl>
</article> </article>
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Internal Notes</p> <p className="section-kicker">INTERNAL NOTES</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text"> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">
{record.notes || "No internal notes recorded for this account yet."} {record.notes || "No internal notes recorded for this account yet."}
</p> </p>
@@ -218,36 +216,35 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
) : null} ) : null}
</article> </article>
</div> </div>
<section className="grid gap-3 xl:grid-cols-4"> <section className="grid gap-2 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Last Contact</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Last Contact</p>
<div className="mt-2 text-base font-bold text-text"> <div className="mt-2 text-base font-bold text-text">
{record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"} {record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"}
</div> </div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Entries</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Entries</p>
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.length}</div> <div className="mt-2 text-base font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.length}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account Contacts</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account Contacts</p>
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</div> <div className="mt-2 text-base font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Attachments</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Attachments</p>
<div className="mt-2 text-base font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div> <div className="mt-2 text-base font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div>
</article> </article>
</section> </section>
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? ( {entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p> <p className="section-kicker">HIERARCHY</p>
<h4 className="mt-2 text-lg font-bold text-text">End customers under this reseller</h4> <div className="mt-3 grid gap-2 xl:grid-cols-2 2xl:grid-cols-3">
<div className="mt-5 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
{record.childCustomers?.map((child) => ( {record.childCustomers?.map((child) => (
<Link <Link
key={child.id} key={child.id}
to={`/crm/customers/${child.id}`} to={`/crm/customers/${child.id}`}
className="rounded-3xl border border-line/70 bg-page/60 px-2 py-2 transition hover:border-brand/50 hover:bg-page/80" className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 transition hover:border-brand/50 hover:bg-page/80"
> >
<div className="text-sm font-semibold text-text">{child.name}</div> <div className="text-sm font-semibold text-text">{child.name}</div>
<div className="mt-2"> <div className="mt-2">
@@ -259,11 +256,10 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</section> </section>
) : null} ) : null}
{entity === "vendor" ? ( {entity === "vendor" ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Activity</p> <p className="section-kicker">PURCHASING ACTIVITY</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canManage ? ( {canManage ? (
@@ -277,15 +273,15 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div> </div>
</div> </div>
{relatedPurchaseOrders.length === 0 ? ( {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-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No purchase orders yet.</div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{relatedPurchaseOrders.slice(0, 8).map((order) => ( {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"> <Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-[18px] 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 className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">{order.documentNumber}</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 className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} - {order.lineCount} lines</div>
</div> </div>
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div> <div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
</div> </div>
@@ -319,13 +315,9 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
/> />
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]"> <section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
{canManage ? ( {canManage ? (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact History</p> <p className="section-kicker">CONTACT HISTORY</p>
<h4 className="mt-2 text-lg font-bold text-text">Add timeline entry</h4> <div className="mt-3">
<p className="mt-2 text-sm text-muted">
Record calls, emails, meetings, and follow-up notes directly against this account.
</p>
<div className="mt-6">
<CrmContactEntryForm <CrmContactEntryForm
form={contactEntryForm} form={contactEntryForm}
isSaving={isSavingContactEntry} isSaving={isSavingContactEntry}
@@ -336,17 +328,16 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div> </div>
</article> </article>
) : null} ) : null}
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timeline</p> <p className="section-kicker">TIMELINE</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent interactions</h4>
{record.contactHistory.length === 0 ? ( {record.contactHistory.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No contact history has been recorded for this account yet. No contact history recorded yet.
</div> </div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{record.contactHistory.map((entry) => ( {record.contactHistory.map((entry) => (
<article key={entry.id} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <article key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -389,3 +380,4 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</section> </section>
); );
} }

View File

@@ -110,17 +110,14 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
} }
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Editor</p> <p className="section-kicker">CRM EDITOR</p>
<h3 className="mt-2 text-xl font-bold text-text"> <h3 className="module-title">
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`} {mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
</h3> </h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Capture the operational contact and address details needed for quoting, purchasing, and shipping workflows.
</p>
</div> </div>
<Link <Link
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`} to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
@@ -130,9 +127,9 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
</Link> </Link>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="space-y-3 surface-panel">
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} /> <CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
<div className="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"> <div className="flex flex-col gap-2.5 rounded-[16px] border border-line/70 bg-page/70 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span> <span className="min-w-0 text-sm text-muted">{status}</span>
<button <button
type="submit" type="submit"
@@ -146,3 +143,4 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
</form> </form>
); );
} }

View File

@@ -55,14 +55,11 @@ export function CrmListPage({ entity }: CrmListPageProps) {
}, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]); }, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
return ( return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p> <p className="section-kicker">CRM</p>
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3> <h3 className="module-title">{config.collectionLabel}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Operational contact records, shipping addresses, and account context for active {config.collectionLabel.toLowerCase()}.
</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link <Link
@@ -73,7 +70,7 @@ export function CrmListPage({ entity }: CrmListPageProps) {
</Link> </Link>
) : null} ) : null}
</div> </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_0.8fr_0.9fr_0.9fr]"> <div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr_0.8fr_0.9fr_0.9fr]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input <input
@@ -137,13 +134,13 @@ export function CrmListPage({ entity }: CrmListPageProps) {
</select> </select>
</label> </label>
</div> </div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
{records.length === 0 ? ( {records.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"> <div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
{config.emptyMessage} {config.emptyMessage}
</div> </div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
@@ -209,3 +206,4 @@ export function CrmListPage({ entity }: CrmListPageProps) {
</section> </section>
); );
} }

View File

@@ -1,7 +1,7 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js"; import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import type { ReactNode } from "react";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { ApiError, api } from "../../lib/api"; import { ApiError, api } from "../../lib/api";
@@ -33,31 +33,75 @@ function formatCurrency(value: number) {
}).format(value); }).format(value);
} }
function formatDateTime(value: string | null) {
if (!value) {
return "No recent activity";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(value));
}
function sumNumber(values: number[]) { function sumNumber(values: number[]) {
return values.reduce((total, value) => total + value, 0); return values.reduce((total, value) => total + value, 0);
} }
function formatPercent(value: number, total: number) {
if (total <= 0) {
return "0%";
}
return `${Math.round((value / total) * 100)}%`;
}
function ProgressBar({
value,
total,
tone,
}: {
value: number;
total: number;
tone: string;
}) {
const width = total > 0 ? Math.max(6, Math.round((value / total) * 100)) : 0;
return (
<div className="h-2 overflow-hidden rounded-full bg-page/80">
<div className={`h-full rounded-full ${tone}`} style={{ width: `${Math.min(width, 100)}%` }} />
</div>
);
}
function StackedBar({
segments,
}: {
segments: Array<{ value: number; tone: string }>;
}) {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
return (
<div className="flex h-3 overflow-hidden rounded-full bg-page/80">
{segments.map((segment, index) => {
const width = total > 0 ? (segment.value / total) * 100 : 0;
return <div key={`${segment.tone}-${index}`} className={segment.tone} style={{ width: `${width}%` }} />;
})}
</div>
);
}
function DashboardCard({
eyebrow,
children,
className = "",
}: {
eyebrow: string;
children: ReactNode;
className?: string;
}) {
return (
<article className={`surface-panel ${className}`.trim()}>
<p className="section-kicker">{eyebrow}</p>
{children}
</article>
);
}
export function DashboardPage() { export function DashboardPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null); const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
const canReadPlanning = hasPermission(user?.permissions, permissions.ganttRead);
useEffect(() => { useEffect(() => {
if (!token || !user) { if (!token || !user) {
@@ -89,9 +133,9 @@ export function DashboardPage() {
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),
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null), canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null), canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null), canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null),
]); ]);
if (!isMounted) { if (!isMounted) {
@@ -148,26 +192,15 @@ export function DashboardPage() {
const projects = snapshot?.projects ?? []; const projects = snapshot?.projects ?? [];
const planningRollup = snapshot?.planningRollup; const planningRollup = snapshot?.planningRollup;
const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null,
snapshot?.items !== null || snapshot?.warehouses !== null,
snapshot?.purchaseOrders !== null,
snapshot?.workOrders !== null,
snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null,
snapshot?.projects !== null,
canReadPlanning,
].filter(Boolean).length;
const customerCount = customers.length; const customerCount = customers.length;
const resellerCount = customers.filter((customer) => customer.isReseller).length;
const activeCustomerCount = customers.filter((customer) => customer.lifecycleStage === "ACTIVE").length; const activeCustomerCount = customers.filter((customer) => customer.lifecycleStage === "ACTIVE").length;
const resellerCount = customers.filter((customer) => customer.isReseller).length;
const strategicCustomerCount = customers.filter((customer) => customer.strategicAccount).length; const strategicCustomerCount = customers.filter((customer) => customer.strategicAccount).length;
const vendorCount = vendors.length; const vendorCount = vendors.length;
const itemCount = items.length; const itemCount = items.length;
const assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length;
const activeItemCount = items.filter((item) => item.status === "ACTIVE").length; const activeItemCount = items.filter((item) => item.status === "ACTIVE").length;
const assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length;
const obsoleteItemCount = items.filter((item) => item.status === "OBSOLETE").length; const obsoleteItemCount = items.filter((item) => item.status === "OBSOLETE").length;
const warehouseCount = warehouses.length; const warehouseCount = warehouses.length;
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount)); const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
@@ -175,16 +208,19 @@ export function DashboardPage() {
const purchaseOrderCount = purchaseOrders.length; const purchaseOrderCount = purchaseOrders.length;
const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length; const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length;
const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length; const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
const closedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "CLOSED").length;
const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total)); 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;
const inProgressWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "IN_PROGRESS").length;
const overdueWorkOrderCount = workOrders.filter((workOrder) => workOrder.dueDate && workOrder.status !== "COMPLETE" && workOrder.status !== "CANCELLED" && new Date(workOrder.dueDate).getTime() < Date.now()).length; const overdueWorkOrderCount = workOrders.filter((workOrder) => workOrder.dueDate && workOrder.status !== "COMPLETE" && workOrder.status !== "CANCELLED" && new Date(workOrder.dueDate).getTime() < Date.now()).length;
const quoteCount = quotes.length; const quoteCount = quotes.length;
const orderCount = orders.length; const orderCount = orders.length;
const draftQuoteCount = quotes.filter((quote) => quote.status === "DRAFT").length; const draftQuoteCount = quotes.filter((quote) => quote.status === "DRAFT").length;
const approvedQuoteCount = quotes.filter((quote) => quote.status === "APPROVED").length;
const issuedOrderCount = orders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length; const issuedOrderCount = orders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
const quoteValue = sumNumber(quotes.map((quote) => quote.total)); const quoteValue = sumNumber(quotes.map((quote) => quote.total));
const orderValue = sumNumber(orders.map((order) => order.total)); const orderValue = sumNumber(orders.map((order) => order.total));
@@ -204,482 +240,350 @@ export function DashboardPage() {
return new Date(project.dueDate).getTime() < Date.now(); return new Date(project.dueDate).getTime() < Date.now();
}).length; }).length;
const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0; const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0;
const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0; const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0;
const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0; const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0;
const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0; const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0;
const planningItemCount = planningRollup?.summary.itemCount ?? 0;
const lastActivityAt = [
...customers.map((customer) => customer.updatedAt),
...vendors.map((vendor) => vendor.updatedAt),
...items.map((item) => item.updatedAt),
...warehouses.map((warehouse) => warehouse.updatedAt),
...purchaseOrders.map((order) => order.updatedAt),
...workOrders.map((workOrder) => workOrder.updatedAt),
...quotes.map((quote) => quote.updatedAt),
...orders.map((order) => order.updatedAt),
...shipments.map((shipment) => shipment.updatedAt),
...projects.map((project) => project.updatedAt),
]
.sort()
.at(-1) ?? null;
const metricCards = [ const metricCards = [
{ {
label: "CRM Accounts", label: "Accounts",
value: snapshot?.customers !== null ? `${customerCount}` : "No access", value: snapshot?.customers !== null ? `${customerCount + vendorCount}` : "No access",
detail: secondary: snapshot?.customers !== null ? `${activeCustomerCount} active customers` : "",
snapshot?.customers !== null tone: "bg-emerald-500",
? `${vendorCount} vendors, ${resellerCount} resellers, ${activeCustomerCount} active`
: "CRM metrics are permission-gated.",
tone: "border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
}, },
{ {
label: "Inventory Footprint", label: "Inventory",
value: snapshot?.items !== null ? `${itemCount}` : "No access", value: snapshot?.items !== null ? `${itemCount}` : "No access",
detail: secondary: snapshot?.items !== null ? `${assemblyCount} buildable items` : "",
snapshot?.items !== null tone: "bg-sky-500",
? `${assemblyCount} buildable items across ${warehouseCount} warehouses`
: "Inventory metrics are permission-gated.",
tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
}, },
{ {
label: "Purchasing Queue", label: "Open Supply",
value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access", value: snapshot?.purchaseOrders !== null || snapshot?.workOrders !== null ? `${openPurchaseOrderCount + activeWorkOrderCount}` : "No access",
detail: secondary: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount} PO | ${activeWorkOrderCount} WO` : "",
snapshot?.purchaseOrders !== null tone: "bg-teal-500",
? `${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: "Commercial",
value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access",
detail:
snapshot?.workOrders !== null
? `${releasedWorkOrderCount} released and ${overdueWorkOrderCount} overdue`
: "Manufacturing metrics are permission-gated.",
tone: "border-indigo-400/30 bg-indigo-500/12 text-indigo-700 dark:text-indigo-300",
},
{
label: "Commercial Value",
value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access", value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access",
detail: secondary: snapshot?.orders !== null ? `${orderCount} orders live` : "",
snapshot?.quotes !== null || snapshot?.orders !== null tone: "bg-amber-500",
? `${quoteCount} quotes and ${orderCount} orders in the pipeline`
: "Sales metrics are permission-gated.",
tone: "border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
}, },
{ {
label: "Shipping Queue", label: "Projects",
value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access",
detail:
snapshot?.shipments !== null
? `${inTransitCount} in transit, ${deliveredCount} delivered`
: "Shipping metrics are permission-gated.",
tone: "border-brand/30 bg-brand/10 text-brand",
},
{
label: "Project Load",
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access", value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
detail: secondary: snapshot?.projects !== null ? `${atRiskProjectCount} at risk` : "",
snapshot?.projects !== null tone: "bg-violet-500",
? `${atRiskProjectCount} at risk and ${overdueProjectCount} overdue`
: "Project metrics are permission-gated.",
tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
}, },
{ {
label: "Material Readiness", label: "Readiness",
value: planningRollup ? `${shortageItemCount}` : "No access", value: planningRollup ? `${shortageItemCount}` : "No access",
detail: planningRollup secondary: planningRollup ? `${totalUncoveredQuantity} units uncovered` : "",
? `${buildRecommendationCount} build and ${buyRecommendationCount} buy recommendations` tone: "bg-rose-500",
: "Sales read permission is required to surface shortage rollups.",
tone: "border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
}, },
]; ];
const modulePanels = [
{
title: "CRM",
eyebrow: "Account Health",
summary:
snapshot?.customers !== null
? "Live account counts, reseller coverage, and strategic-account concentration from the current CRM records."
: "CRM read permission is required to surface customer and vendor metrics here.",
metrics: [
{ label: "Customers", value: snapshot?.customers !== null ? `${customerCount}` : "No access" },
{ label: "Strategic", value: snapshot?.customers !== null ? `${strategicCustomerCount}` : "No access" },
{ label: "Vendors", value: snapshot?.vendors !== null ? `${vendorCount}` : "No access" },
],
links: [
{ label: "Open customers", to: "/crm/customers" },
{ label: "Open vendors", to: "/crm/vendors" },
],
},
{
title: "Inventory",
eyebrow: "Master + Stock",
summary:
snapshot?.items !== null
? "Item master, BOM-capable parts, and warehouse footprint are now feeding the dashboard directly."
: "Inventory read permission is required to surface item and warehouse metrics here.",
metrics: [
{ label: "Active items", value: snapshot?.items !== null ? `${activeItemCount}` : "No access" },
{ label: "Assemblies", value: snapshot?.items !== null ? `${assemblyCount}` : "No access" },
{ label: "Locations", value: snapshot?.warehouses !== null ? `${locationCount}` : "No access" },
],
links: [
{ label: "Open inventory", to: "/inventory/items" },
{ 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",
eyebrow: "Execution Load",
summary:
snapshot?.workOrders !== null
? "Work orders, released load, and overdue build pressure are now visible from the dashboard."
: "Manufacturing read permission is required to surface work-order metrics here.",
metrics: [
{ label: "Open work", value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access" },
{ label: "Released", value: snapshot?.workOrders !== null ? `${releasedWorkOrderCount}` : "No access" },
{ label: "Overdue", value: snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access" },
],
links: [
{ label: "Open work orders", to: "/manufacturing/work-orders" },
...(canWriteManufacturing ? [{ label: "New work order", to: "/manufacturing/work-orders/new" }] : []),
],
},
{
title: "Sales",
eyebrow: "Revenue Flow",
summary:
snapshot?.quotes !== null || snapshot?.orders !== null
? "Quotes and sales orders now contribute real commercial value, open-document counts, and pipeline visibility."
: "Sales read permission is required to surface commercial metrics here.",
metrics: [
{ label: "Quote value", value: snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access" },
{ label: "Order value", value: snapshot?.orders !== null ? formatCurrency(orderValue) : "No access" },
{ label: "Draft quotes", value: snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access" },
],
links: [
{ label: "Open quotes", to: "/sales/quotes" },
{ label: "Open sales orders", to: "/sales/orders" },
],
},
{
title: "Shipping",
eyebrow: "Execution Queue",
summary:
snapshot?.shipments !== null
? "Shipment records, in-transit volume, and completed deliveries are now visible from the landing page."
: "Shipping read permission is required to surface shipment metrics here.",
metrics: [
{ label: "Open shipments", value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access" },
{ label: "In transit", value: snapshot?.shipments !== null ? `${inTransitCount}` : "No access" },
{ label: "Delivered", value: snapshot?.shipments !== null ? `${deliveredCount}` : "No access" },
],
links: [
{ label: "Open shipments", to: "/shipping/shipments" },
{ label: "Open packing flow", to: "/sales/orders" },
],
},
{
title: "Projects",
eyebrow: "Program Control",
summary:
snapshot?.projects !== null
? "Project records now tie customers, commercial documents, shipment context, and delivery ownership into one operational surface."
: "Project read permission is required to surface program metrics here.",
metrics: [
{ label: "Active", value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access" },
{ label: "At risk", value: snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access" },
{ label: "Overdue", value: snapshot?.projects !== null ? `${overdueProjectCount}` : "No access" },
],
links: [
{ label: "Open projects", to: "/projects" },
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
],
},
{
title: "Planning",
eyebrow: "Schedule Visibility",
summary: canReadPlanning
? "Live gantt planning now pulls directly from active projects and open manufacturing work orders, with shared shortage/readiness rollups alongside schedule pressure."
: "Planning read permission is required to surface the live gantt schedule.",
metrics: [
{ label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" },
{ label: "Shortage items", value: canReadPlanning && planningRollup ? `${shortageItemCount}` : "No access" },
{ label: "Build / buy", value: canReadPlanning && planningRollup ? `${buildRecommendationCount} / ${buyRecommendationCount}` : "No access" },
],
links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [],
},
];
const futureModules = [
"Stock transfers, allocations, and cycle counts",
"Revision comparison and document restore tooling",
"Audit trails, diagnostics, and system health checks",
];
return ( return (
<div className="space-y-4"> <div className="page-stack">
<section className="overflow-hidden rounded-[30px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur"> {error ? <div className="rounded-[16px] border border-amber-400/30 bg-amber-500/12 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
<div className="grid gap-0 xl:grid-cols-[1.35fr_0.65fr]">
<div className="relative overflow-hidden p-5 2xl:p-6">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(24,90,219,0.16),_transparent_44%),linear-gradient(135deg,rgba(255,255,255,0.06),transparent)]" />
<div className="relative">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Dashboard</p>
<h3 className="mt-2 max-w-3xl text-2xl font-extrabold text-text">Operational command surface for metrics, movement, and next actions.</h3>
</div>
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2 text-right">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted">Last Refresh</p>
<p className="mt-1 text-sm font-semibold text-text">{snapshot ? formatDateTime(snapshot.refreshedAt) : "Waiting"}</p>
</div>
</div>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
This landing page now reads directly from live CRM, inventory, purchasing, manufacturing, sales, shipping, and project data. It is
intentionally modular so future planning, approvals, and audit slices can slot into the same command surface without a redesign.
</p>
<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">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Modules Live</p>
<p className="mt-1 text-lg font-extrabold text-text">{accessibleModules}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Recent Activity</p>
<p className="mt-1 text-sm font-semibold text-text">{formatDateTime(lastActivityAt)}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Loading State</p>
<p className="mt-1 text-sm font-semibold text-text">{isLoading ? "Refreshing data" : "Live snapshot loaded"}</p>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<Link className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" to="/sales/orders">
Open sales orders
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/shipping/shipments">
Open shipments
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items">
Open inventory
</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">
Open projects
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/manufacturing/work-orders">
Open manufacturing
</Link>
{canReadPlanning ? (
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/planning/gantt">
Open planning
</Link>
) : null}
</div>
{error ? <div className="mt-4 rounded-2xl border border-amber-400/30 bg-amber-500/12 px-2 py-2 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
</div>
</div>
<div className="border-l border-line/70 bg-page/40 p-5 2xl:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Upgrade Path</p>
<div className="mt-4 space-y-3">
{futureModules.map((item) => (
<div key={item} className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2 text-sm text-text">
{item}
</div>
))}
</div>
</div>
</div>
</section>
<section className="grid gap-3 xl:grid-cols-7">
{metricCards.map((card) => (
<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>
<div className="mt-2 flex items-center justify-between gap-3">
<div className="text-xl font-extrabold text-text">{card.value}</div>
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${card.tone}`}>Live</span>
</div>
<p className="mt-2 text-sm text-muted">{card.detail}</p>
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-7">
{modulePanels.map((panel) => (
<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>
<h4 className="mt-2 text-lg font-bold text-text">{panel.title}</h4>
<p className="mt-3 text-sm leading-6 text-muted">{panel.summary}</p>
<div className="mt-5 grid gap-2">
{panel.metrics.map((metric) => (
<div key={metric.label} 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">{metric.label}</span>
<span className="font-semibold text-text">{metric.value}</span>
</div>
))}
</div>
<div className="mt-5 flex flex-wrap gap-2">
{panel.links.map((link) => (
<Link key={link.to} to={link.to} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
{link.label}
</Link>
))}
</div>
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-6"> <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"> {metricCards.map((card) => (
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning Watch</p> <article key={card.label} className="surface-panel-tight">
<h4 className="mt-2 text-lg font-bold text-text">Shared shortage and readiness</h4> <p className="metric-kicker">{card.label}</p>
<div className="mt-4 grid gap-2"> <div className="mt-1.5 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="mt-1.5 flex items-center gap-2.5">
<span className="text-muted">Shortage items</span> <div className="h-2 flex-1 overflow-hidden rounded-full bg-page/80">
<span className="font-semibold text-text">{planningRollup ? `${shortageItemCount}` : "No access"}</span> <div className={`h-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} />
</div>
<span className="text-xs text-muted">Live</span>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> {card.secondary ? <div className="mt-1.5 text-xs text-muted">{card.secondary}</div> : null}
<span className="text-muted">Build recommendations</span> </article>
<span className="font-semibold text-text">{planningRollup ? `${buildRecommendationCount}` : "No access"}</span> ))}
</section>
<section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]">
<DashboardCard eyebrow="COMMERCIAL">
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<div className="surface-panel-tight">
<div className="metric-kicker">Quotes</div>
<div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div>
<div className="mt-2.5 space-y-2">
<div className="flex items-center justify-between text-xs text-muted">
<span>Draft</span>
<span>{draftQuoteCount}</span>
</div>
<ProgressBar value={draftQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-amber-500" />
<div className="flex items-center justify-between text-xs text-muted">
<span>Approved</span>
<span>{approvedQuoteCount}</span>
</div>
<ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" />
</div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">Buy recommendations</span> <div className="metric-kicker">Orders</div>
<span className="font-semibold text-text">{planningRollup ? `${buyRecommendationCount}` : "No access"}</span> <div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</div>
</div> <div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div>
<span className="text-muted">Uncovered qty</span> <div className="text-xs text-muted">Issued / approved</div>
<span className="font-semibold text-text">{planningRollup ? `${totalUncoveredQuantity}` : "No access"}</span> <div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div>
</div>
<div>
<div className="text-xs text-muted">Total orders</div>
<div className="mt-1 text-lg font-semibold text-text">{orderCount}</div>
</div>
</div>
<div className="mt-2.5">
<ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" />
</div>
</div> </div>
</div> </div>
</article> </DashboardCard>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <DashboardCard eyebrow="CRM">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Watch</p> <div className="mt-3 space-y-3">
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4> <div>
<div className="mt-4 grid gap-2"> <div className="flex items-center justify-between text-sm">
<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">Active customers</span>
<span className="text-muted">Obsolete items</span> <span className="font-semibold text-text">{snapshot?.customers !== null ? formatPercent(activeCustomerCount, Math.max(customerCount, 1)) : "No access"}</span>
<span className="font-semibold text-text">{snapshot?.items !== null ? `${obsoleteItemCount}` : "No access"}</span> </div>
<div className="mt-2">
<ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" />
</div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="grid gap-2.5 sm:grid-cols-3">
<span className="text-muted">Warehouse count</span> <div className="surface-panel-tight">
<span className="font-semibold text-text">{snapshot?.warehouses !== null ? `${warehouseCount}` : "No access"}</span> <div className="metric-kicker">Customers</div>
<div className="mt-1 text-lg font-bold text-text">{customerCount}</div>
</div>
<div className="surface-panel-tight">
<div className="metric-kicker">Resellers</div>
<div className="mt-1 text-lg font-bold text-text">{resellerCount}</div>
</div>
<div className="surface-panel-tight">
<div className="metric-kicker">Vendors</div>
<div className="mt-1 text-lg font-bold text-text">{vendorCount}</div>
</div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">Stock locations</span> <div className="flex items-center justify-between text-sm">
<span className="font-semibold text-text">{snapshot?.warehouses !== null ? `${locationCount}` : "No access"}</span> <span className="text-muted">Strategic accounts</span>
<span className="font-semibold text-text">{strategicCustomerCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={strategicCustomerCount} total={Math.max(customerCount, 1)} tone="bg-violet-500" />
</div>
</div> </div>
</div> </div>
</article> </DashboardCard>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> </section>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sales Watch</p> <section className="grid gap-3 xl:grid-cols-3">
<h4 className="mt-2 text-lg font-bold text-text">Commercial flow snapshot</h4> <DashboardCard eyebrow="INVENTORY">
<div className="mt-4 grid gap-2"> <div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">Issued orders</span> <div className="metric-kicker">Item Mix</div>
<span className="font-semibold text-text">{snapshot?.orders !== null ? `${issuedOrderCount}` : "No access"}</span> <div className="mt-2.5 space-y-2.5">
<div>
<div className="flex items-center justify-between text-xs text-muted">
<span>Active items</span>
<span>{activeItemCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={activeItemCount} total={Math.max(itemCount, 1)} tone="bg-sky-500" />
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs text-muted">
<span>Buildable items</span>
<span>{assemblyCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={assemblyCount} total={Math.max(itemCount, 1)} tone="bg-indigo-500" />
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs text-muted">
<span>Obsolete items</span>
<span>{obsoleteItemCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={obsoleteItemCount} total={Math.max(itemCount, 1)} tone="bg-slate-500" />
</div>
</div>
</div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">Draft quotes</span> <div className="metric-kicker">Storage</div>
<span className="font-semibold text-text">{snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access"}</span> <div className="mt-2.5 grid gap-2.5">
</div> <div className="surface-panel-tight bg-surface/80">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="text-xs text-muted">Warehouses</div>
<span className="text-muted">Order backlog</span> <div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div>
<span className="font-semibold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</span> </div>
<div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Locations</div>
<div className="mt-1 text-lg font-bold text-text">{locationCount}</div>
</div>
</div>
</div> </div>
</div> </div>
</article> </DashboardCard>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <DashboardCard eyebrow="SUPPLY">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Watch</p> <div className="mt-3 surface-panel-tight">
<h4 className="mt-2 text-lg font-bold text-text">Inbound supply and commitment load</h4> <div className="metric-kicker">Open Workload</div>
<div className="mt-4 grid gap-2"> <div className="mt-2.5">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <StackedBar
<span className="text-muted">Total purchase orders</span> segments={[
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${purchaseOrderCount}` : "No access"}</span> { value: openPurchaseOrderCount, tone: "bg-teal-500" },
{ value: releasedWorkOrderCount, tone: "bg-indigo-500" },
{ value: inProgressWorkOrderCount, tone: "bg-amber-500" },
{ value: overdueWorkOrderCount, tone: "bg-rose-500" },
]}
/>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<span className="text-muted">Open queue</span> <div className="surface-panel-tight bg-surface/80">
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access"}</span> <div className="text-xs text-muted">Open PO queue</div>
</div> <div className="mt-1 text-lg font-bold text-text">{openPurchaseOrderCount}</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="mt-1 text-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div>
<span className="text-muted">Committed value</span> </div>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access"}</span> <div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Active work orders</div>
<div className="mt-1 text-lg font-bold text-text">{activeWorkOrderCount}</div>
<div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div>
</div>
<div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Issued / approved POs</div>
<div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div>
</div>
<div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Released WOs</div>
<div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div>
</div>
</div> </div>
</div> </div>
</article> </DashboardCard>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <DashboardCard eyebrow="READINESS">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Watch</p> <div className="mt-3 space-y-2.5">
<h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</h4> <div className="surface-panel-tight">
<div className="mt-4 grid gap-2"> <div className="flex items-center justify-between text-sm">
<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">Shortage items</span>
<span className="text-muted">Total work orders</span> <span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span>
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${workOrderCount}` : "No access"}</span> </div>
<div className="mt-2">
<ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" />
</div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">Active queue</span> <div className="metric-kicker">Build Vs Buy</div>
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access"}</span> <div className="mt-2.5">
<StackedBar
segments={[
{ value: buildRecommendationCount, tone: "bg-indigo-500" },
{ value: buyRecommendationCount, tone: "bg-teal-500" },
]}
/>
</div>
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
<div>
<div className="text-xs text-muted">Build recommendations</div>
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buildRecommendationCount : "No access"}</div>
</div>
<div>
<div className="text-xs text-muted">Buy recommendations</div>
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buyRecommendationCount : "No access"}</div>
</div>
</div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">Overdue</span> <div className="text-xs text-muted">Uncovered quantity</div>
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access"}</span> <div className="mt-1 text-lg font-bold text-text">{planningRollup ? totalUncoveredQuantity : "No access"}</div>
</div> </div>
</div> </div>
</article> </DashboardCard>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> </section>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project Watch</p> <section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]">
<h4 className="mt-2 text-lg font-bold text-text">Program status and delivery pressure</h4> <DashboardCard eyebrow="PROGRAMS">
<div className="mt-4 grid gap-2"> <div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">Total projects</span> <div className="metric-kicker">Projects</div>
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${projectCount}` : "No access"}</span> <div className="mt-2.5">
<StackedBar
segments={[
{ value: activeProjectCount, tone: "bg-violet-500" },
{ value: atRiskProjectCount, tone: "bg-amber-500" },
{ value: overdueProjectCount, tone: "bg-rose-500" },
]}
/>
</div>
<div className="mt-3 grid gap-2.5 sm:grid-cols-3">
<div>
<div className="text-xs text-muted">Active</div>
<div className="mt-1 font-semibold text-text">{activeProjectCount}</div>
</div>
<div>
<div className="text-xs text-muted">At risk</div>
<div className="mt-1 font-semibold text-text">{atRiskProjectCount}</div>
</div>
<div>
<div className="text-xs text-muted">Overdue</div>
<div className="mt-1 font-semibold text-text">{overdueProjectCount}</div>
</div>
</div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="surface-panel-tight">
<span className="text-muted">At risk</span> <div className="metric-kicker">Shipping</div>
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access"}</span> <div className="mt-2.5">
</div> <StackedBar
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> segments={[
<span className="text-muted">Overdue</span> { value: activeShipmentCount, tone: "bg-brand" },
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${overdueProjectCount}` : "No access"}</span> { value: inTransitCount, tone: "bg-sky-500" },
{ value: deliveredCount, tone: "bg-emerald-500" },
]}
/>
</div>
<div className="mt-3 grid gap-2.5 sm:grid-cols-3">
<div>
<div className="text-xs text-muted">Open</div>
<div className="mt-1 font-semibold text-text">{activeShipmentCount}</div>
</div>
<div>
<div className="text-xs text-muted">In transit</div>
<div className="mt-1 font-semibold text-text">{inTransitCount}</div>
</div>
<div>
<div className="text-xs text-muted">Delivered</div>
<div className="mt-1 font-semibold text-text">{deliveredCount}</div>
</div>
</div>
</div> </div>
</div> </div>
</article> </DashboardCard>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <DashboardCard eyebrow="OPERATIONS">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Watch</p> <div className="mt-3 space-y-2.5">
<h4 className="mt-2 text-lg font-bold text-text">Execution and delivery status</h4> {[
<div className="mt-4 grid gap-2"> { label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" },
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> { label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" },
<span className="text-muted">Total shipments</span> { label: "Sales orders", value: orderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-amber-500" },
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${shipmentCount}` : "No access"}</span> { label: "Purchase orders", value: purchaseOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-teal-500" },
</div> { label: "Work orders", value: workOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-indigo-500" },
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> { label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" },
<span className="text-muted">Open queue</span> { label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" },
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access"}</span> ].map((row) => (
</div> <div key={row.label} className="surface-panel-tight">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted">Delivered</span> <span className="text-muted">{row.label}</span>
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${deliveredCount}` : "No access"}</span> <span className="font-semibold text-text">{row.value}</span>
</div> </div>
<div className="mt-2">
<ProgressBar value={row.value} total={row.total} tone={row.tone} />
</div>
</div>
))}
</div> </div>
</article> {snapshot ? <div className="mt-3 text-xs text-muted">REFRESHED {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null}
</DashboardCard>
</section> </section>
</div> </div>
); );

View File

@@ -0,0 +1,469 @@
import { permissions } from "@mrp/shared";
import type {
CapexCategory,
CapexStatus,
FinanceCapexInput,
FinanceCustomerPaymentInput,
FinanceDashboardDto,
FinancePaymentMethod,
FinancePaymentType,
FinanceProfileInput,
} from "@mrp/shared";
import { capexCategories, capexStatuses, financePaymentMethods, financePaymentTypes } 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";
function formatCurrency(value: number, currencyCode = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
maximumFractionDigits: 0,
}).format(value);
}
function formatPercent(value: number) {
return `${value.toFixed(1)}%`;
}
export function FinancePage() {
const { token, user } = useAuth();
const canManage = user?.permissions.includes(permissions.financeWrite) ?? false;
const [dashboard, setDashboard] = useState<FinanceDashboardDto | null>(null);
const [salesOrders, setSalesOrders] = useState<Awaited<ReturnType<typeof api.getSalesOrders>>>([]);
const [purchaseOrders, setPurchaseOrders] = useState<Awaited<ReturnType<typeof api.getPurchaseOrders>>>([]);
const [vendors, setVendors] = useState<Awaited<ReturnType<typeof api.getPurchaseVendors>>>([]);
const [status, setStatus] = useState("Loading finance workbench...");
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isPostingPayment, setIsPostingPayment] = useState(false);
const [isSavingCapex, setIsSavingCapex] = useState(false);
const [editingCapexId, setEditingCapexId] = useState<string | null>(null);
const [profileForm, setProfileForm] = useState<FinanceProfileInput>({
currencyCode: "USD",
standardLaborRatePerHour: 45,
overheadRatePerHour: 18,
});
const [paymentForm, setPaymentForm] = useState<FinanceCustomerPaymentInput>({
salesOrderId: "",
paymentType: "DEPOSIT",
paymentMethod: "ACH",
paymentDate: new Date().toISOString(),
amount: 0,
reference: "",
notes: "",
});
const [capexForm, setCapexForm] = useState<FinanceCapexInput>({
title: "",
category: "EQUIPMENT",
status: "PLANNED",
vendorId: null,
purchaseOrderId: null,
plannedAmount: 0,
actualAmount: 0,
requestDate: new Date().toISOString(),
targetInServiceDate: null,
purchasedAt: null,
notes: "",
});
async function loadFinance(activeToken: string) {
const [nextDashboard, nextSalesOrders, nextPurchaseOrders, nextVendors] = await Promise.all([
api.getFinanceDashboard(activeToken),
api.getSalesOrders(activeToken),
api.getPurchaseOrders(activeToken),
api.getPurchaseVendors(activeToken),
]);
setDashboard(nextDashboard);
setSalesOrders(nextSalesOrders);
setPurchaseOrders(nextPurchaseOrders);
setVendors(nextVendors);
setProfileForm({
currencyCode: nextDashboard.profile.currencyCode,
standardLaborRatePerHour: nextDashboard.profile.standardLaborRatePerHour,
overheadRatePerHour: nextDashboard.profile.overheadRatePerHour,
});
setPaymentForm((current) => ({
...current,
salesOrderId: current.salesOrderId || nextSalesOrders[0]?.id || "",
}));
setStatus("Finance workbench loaded.");
}
useEffect(() => {
if (!token) {
return;
}
loadFinance(token).catch((error: unknown) => {
setStatus(error instanceof ApiError ? error.message : "Unable to load finance workbench.");
});
}, [token]);
function resetCapexForm() {
setEditingCapexId(null);
setCapexForm({
title: "",
category: "EQUIPMENT",
status: "PLANNED",
vendorId: null,
purchaseOrderId: null,
plannedAmount: 0,
actualAmount: 0,
requestDate: new Date().toISOString(),
targetInServiceDate: null,
purchasedAt: null,
notes: "",
});
}
async function handleSaveProfile() {
if (!token) {
return;
}
setIsSavingProfile(true);
setStatus("Saving finance assumptions...");
try {
const nextProfile = await api.updateFinanceProfile(token, profileForm);
setDashboard((current) => (current ? { ...current, profile: nextProfile } : current));
setStatus("Finance assumptions updated.");
} catch (error: unknown) {
setStatus(error instanceof ApiError ? error.message : "Unable to save finance assumptions.");
} finally {
setIsSavingProfile(false);
}
}
async function handlePostPayment() {
if (!token) {
return;
}
setIsPostingPayment(true);
setStatus("Posting customer payment...");
try {
await api.createFinancePayment(token, paymentForm);
await loadFinance(token);
setPaymentForm((current) => ({
...current,
amount: 0,
reference: "",
notes: "",
paymentDate: new Date().toISOString(),
}));
setStatus("Customer payment posted.");
} catch (error: unknown) {
setStatus(error instanceof ApiError ? error.message : "Unable to post customer payment.");
} finally {
setIsPostingPayment(false);
}
}
async function handleSaveCapex() {
if (!token) {
return;
}
setIsSavingCapex(true);
setStatus(editingCapexId ? "Updating CapEx entry..." : "Creating CapEx entry...");
try {
if (editingCapexId) {
await api.updateCapexEntry(token, editingCapexId, capexForm);
} else {
await api.createCapexEntry(token, capexForm);
}
await loadFinance(token);
resetCapexForm();
setStatus(editingCapexId ? "CapEx entry updated." : "CapEx entry created.");
} catch (error: unknown) {
setStatus(error instanceof ApiError ? error.message : "Unable to save CapEx entry.");
} finally {
setIsSavingCapex(false);
}
}
if (!dashboard) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
const { profile, summary, salesOrderLedgers, payments, capex } = dashboard;
const currencyCode = profile.currencyCode || "USD";
return (
<section className="page-stack">
<div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="section-kicker">FINANCE</p>
<h2 className="module-title">CASH SPEND CAPEX</h2>
</div>
<div className="surface-panel-tight text-sm text-muted">
SNAPSHOT {new Date(dashboard.generatedAt).toLocaleString()}
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-6">
{[
{ label: "Booked Revenue", value: formatCurrency(summary.bookedRevenue, currencyCode) },
{ label: "Payments In", value: formatCurrency(summary.paymentsReceived, currencyCode) },
{ label: "A/R Open", value: formatCurrency(summary.accountsReceivableOpen, currencyCode) },
{ label: "PO Spend", value: formatCurrency(summary.linkedPurchaseReceivedValue, currencyCode) },
{ label: "Mfg Cost", value: formatCurrency(summary.manufacturingTotalCost, currencyCode) },
{ label: "CapEx Actual", value: formatCurrency(summary.capexActual, currencyCode) },
].map((card) => (
<article key={card.label} className="surface-panel-tight bg-surface/90 shadow-panel">
<p className="metric-kicker">{card.label}</p>
<div className="mt-1.5 text-xl font-extrabold text-text">{card.value}</div>
</article>
))}
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<article className="surface-panel">
<div className="flex items-center justify-between gap-3">
<p className="section-kicker">SALES ORDER LEDGER</p>
</div>
<div className="mt-3 overflow-x-auto">
<table className="min-w-full divide-y divide-line/60 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-[0.16em] text-muted">
<th className="pb-3 pr-3 font-semibold">Order</th>
<th className="pb-3 pr-3 font-semibold">Revenue</th>
<th className="pb-3 pr-3 font-semibold">Payments</th>
<th className="pb-3 pr-3 font-semibold">PO</th>
<th className="pb-3 pr-3 font-semibold">Manufacturing</th>
<th className="pb-3 pr-3 font-semibold">Spend</th>
<th className="pb-3 font-semibold">Margin</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{salesOrderLedgers.map((ledger) => (
<tr key={ledger.salesOrderId}>
<td className="py-3 pr-3 align-top">
<Link to={`/sales/orders/${ledger.salesOrderId}`} className="font-semibold text-brand hover:underline">
{ledger.salesOrderNumber}
</Link>
<div className="text-xs text-muted">{ledger.customerName}</div>
<div className="text-xs text-muted">
{ledger.linkedPurchaseOrderCount} PO / {ledger.linkedWorkOrderCount} WO
</div>
</td>
<td className="py-3 pr-3 align-top text-text">{formatCurrency(ledger.revenueTotal, currencyCode)}</td>
<td className="py-3 pr-3 align-top">
<div className="text-text">{formatCurrency(ledger.paymentsReceived, currencyCode)}</div>
<div className="text-xs text-muted">A/R {formatCurrency(ledger.accountsReceivableOpen, currencyCode)}</div>
</td>
<td className="py-3 pr-3 align-top">
<div className="text-text">{formatCurrency(ledger.linkedPurchaseReceivedValue, currencyCode)}</div>
<div className="text-xs text-muted">Committed {formatCurrency(ledger.linkedPurchaseCommitted, currencyCode)}</div>
</td>
<td className="py-3 pr-3 align-top">
<div className="text-text">{formatCurrency(ledger.manufacturingTotalCost, currencyCode)}</div>
<div className="text-xs text-muted">
Mat {formatCurrency(ledger.manufacturingMaterialCost, currencyCode)} / Lab+OH {formatCurrency(ledger.manufacturingLaborCost + ledger.manufacturingOverheadCost, currencyCode)}
</div>
</td>
<td className="py-3 pr-3 align-top">
<div className="text-text">{formatCurrency(ledger.totalRecognizedSpend, currencyCode)}</div>
<div className="text-xs text-muted">Coverage {formatPercent(ledger.paymentCoveragePercent)}</div>
</td>
<td className="py-3 align-top">
<div className={`font-semibold ${ledger.grossMarginEstimate >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
{formatCurrency(ledger.grossMarginEstimate, currencyCode)}
</div>
<div className="text-xs text-muted">{formatPercent(ledger.grossMarginPercent)}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
<div className="space-y-3">
<article className="surface-panel">
<p className="section-kicker">COSTING ASSUMPTIONS</p>
<div className="mt-3 grid gap-2.5">
<label className="text-sm text-text">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Currency</span>
<input value={profileForm.currencyCode} onChange={(event) => setProfileForm((current) => ({ ...current, currencyCode: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 outline-none" />
</label>
<label className="text-sm text-text">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Labor Rate / Hour</span>
<input type="number" step="0.01" min={0} value={profileForm.standardLaborRatePerHour} onChange={(event) => setProfileForm((current) => ({ ...current, standardLaborRatePerHour: Number(event.target.value) }))} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 outline-none" />
</label>
<label className="text-sm text-text">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Overhead / Labor Hour</span>
<input type="number" step="0.01" min={0} value={profileForm.overheadRatePerHour} onChange={(event) => setProfileForm((current) => ({ ...current, overheadRatePerHour: Number(event.target.value) }))} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 outline-none" />
</label>
</div>
{canManage ? (
<button type="button" onClick={() => void handleSaveProfile()} disabled={isSavingProfile} className="mt-3 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{isSavingProfile ? "Saving..." : "Save assumptions"}
</button>
) : null}
</article>
<article className="surface-panel">
<p className="section-kicker">POST PAYMENT</p>
<div className="mt-3 grid gap-2.5">
<select value={paymentForm.salesOrderId} onChange={(event) => setPaymentForm((current) => ({ ...current, salesOrderId: event.target.value }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
{salesOrders.map((order) => (
<option key={order.id} value={order.id}>
{order.documentNumber} / {order.customerName}
</option>
))}
</select>
<div className="grid gap-3 sm:grid-cols-2">
<select value={paymentForm.paymentType} onChange={(event) => setPaymentForm((current) => ({ ...current, paymentType: event.target.value as FinancePaymentType }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
{financePaymentTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
<select value={paymentForm.paymentMethod} onChange={(event) => setPaymentForm((current) => ({ ...current, paymentMethod: event.target.value as FinancePaymentMethod }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
{financePaymentMethods.map((method) => <option key={method} value={method}>{method}</option>)}
</select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<input type="datetime-local" value={paymentForm.paymentDate.slice(0, 16)} onChange={(event) => setPaymentForm((current) => ({ ...current, paymentDate: new Date(event.target.value).toISOString() }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
<input type="number" min={0} step="0.01" value={paymentForm.amount} onChange={(event) => setPaymentForm((current) => ({ ...current, amount: Number(event.target.value) }))} placeholder="Amount" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
</div>
<input value={paymentForm.reference} onChange={(event) => setPaymentForm((current) => ({ ...current, reference: event.target.value }))} placeholder="Reference / remittance" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
<textarea value={paymentForm.notes} onChange={(event) => setPaymentForm((current) => ({ ...current, notes: event.target.value }))} placeholder="Notes" rows={3} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
</div>
{canManage ? (
<button type="button" onClick={() => void handlePostPayment()} disabled={isPostingPayment || !paymentForm.salesOrderId || paymentForm.amount <= 0} className="mt-3 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{isPostingPayment ? "Posting..." : "Post payment"}
</button>
) : null}
</article>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div><p className="section-kicker">RECENT PAYMENTS</p></div>
</div>
<div className="mt-3 space-y-2.5">
{payments.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No payments posted yet.</div>
) : (
payments.map((payment) => (
<div key={payment.id} className="surface-panel-tight">
<div className="flex items-start justify-between gap-3">
<div>
<Link to={`/sales/orders/${payment.salesOrderId}`} className="font-semibold text-brand hover:underline">
{payment.salesOrderNumber}
</Link>
<div className="text-xs text-muted">{payment.customerName}</div>
<div className="mt-1 text-sm text-text">{payment.paymentType} via {payment.paymentMethod}</div>
</div>
<div className="text-right">
<div className="font-semibold text-text">{formatCurrency(payment.amount, currencyCode)}</div>
<div className="text-xs text-muted">{new Date(payment.paymentDate).toLocaleString()}</div>
</div>
</div>
<div className="mt-2 text-xs text-muted">{payment.reference || "No reference"} / {payment.createdByName}</div>
</div>
))
)}
</div>
</article>
<article className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div><p className="section-kicker">CAPEX TRACKER</p></div>
{editingCapexId ? (
<button type="button" onClick={resetCapexForm} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Clear edit
</button>
) : null}
</div>
<div className="mt-3 grid gap-2.5 lg:grid-cols-2">
<input value={capexForm.title} onChange={(event) => setCapexForm((current) => ({ ...current, title: event.target.value }))} placeholder="CapEx title" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
<select value={capexForm.category} onChange={(event) => setCapexForm((current) => ({ ...current, category: event.target.value as CapexCategory }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
{capexCategories.map((category) => <option key={category} value={category}>{category}</option>)}
</select>
<select value={capexForm.status} onChange={(event) => setCapexForm((current) => ({ ...current, status: event.target.value as CapexStatus }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
{capexStatuses.map((capexStatus) => <option key={capexStatus} value={capexStatus}>{capexStatus}</option>)}
</select>
<select value={capexForm.vendorId ?? ""} onChange={(event) => setCapexForm((current) => ({ ...current, vendorId: event.target.value || null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
<option value="">No vendor linked</option>
{vendors.map((vendor) => <option key={vendor.id} value={vendor.id}>{vendor.name}</option>)}
</select>
<select value={capexForm.purchaseOrderId ?? ""} onChange={(event) => setCapexForm((current) => ({ ...current, purchaseOrderId: event.target.value || null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
<option value="">No purchase order linked</option>
{purchaseOrders.map((order) => <option key={order.id} value={order.id}>{order.documentNumber} / {order.vendorName}</option>)}
</select>
<div className="grid gap-3 sm:grid-cols-2">
<input type="number" min={0} step="0.01" value={capexForm.plannedAmount} onChange={(event) => setCapexForm((current) => ({ ...current, plannedAmount: Number(event.target.value) }))} placeholder="Planned amount" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
<input type="number" min={0} step="0.01" value={capexForm.actualAmount} onChange={(event) => setCapexForm((current) => ({ ...current, actualAmount: Number(event.target.value) }))} placeholder="Actual amount" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
</div>
<input type="date" value={capexForm.requestDate.slice(0, 10)} onChange={(event) => setCapexForm((current) => ({ ...current, requestDate: new Date(`${event.target.value}T00:00:00`).toISOString() }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
<input type="date" value={capexForm.targetInServiceDate ? capexForm.targetInServiceDate.slice(0, 10) : ""} onChange={(event) => setCapexForm((current) => ({ ...current, targetInServiceDate: event.target.value ? new Date(`${event.target.value}T00:00:00`).toISOString() : null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
<input type="date" value={capexForm.purchasedAt ? capexForm.purchasedAt.slice(0, 10) : ""} onChange={(event) => setCapexForm((current) => ({ ...current, purchasedAt: event.target.value ? new Date(`${event.target.value}T00:00:00`).toISOString() : null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
<textarea value={capexForm.notes} onChange={(event) => setCapexForm((current) => ({ ...current, notes: event.target.value }))} placeholder="Business justification, install notes, or sourcing detail" rows={3} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none lg:col-span-2" />
</div>
{canManage ? (
<button type="button" onClick={() => void handleSaveCapex()} disabled={isSavingCapex || !capexForm.title.trim()} className="mt-3 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{isSavingCapex ? "Saving..." : editingCapexId ? "Update CapEx" : "Create CapEx"}
</button>
) : null}
<div className="mt-3 space-y-2.5">
{capex.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No CapEx entries yet.</div>
) : (
capex.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => {
setEditingCapexId(entry.id);
setCapexForm({
title: entry.title,
category: entry.category,
status: entry.status,
vendorId: entry.vendorId,
purchaseOrderId: entry.purchaseOrderId,
plannedAmount: entry.plannedAmount,
actualAmount: entry.actualAmount,
requestDate: entry.requestDate,
targetInServiceDate: entry.targetInServiceDate,
purchasedAt: entry.purchasedAt,
notes: entry.notes,
});
}}
className="block w-full rounded-[16px] border border-line/70 bg-page/60 px-3 py-2.5 text-left transition hover:bg-page/80"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">{entry.title}</div>
<div className="text-xs text-muted">
{entry.category} / {entry.status}
{entry.vendorName ? ` / ${entry.vendorName}` : ""}
{entry.purchaseOrderNumber ? ` / ${entry.purchaseOrderNumber}` : ""}
</div>
</div>
<div className="text-right">
<div className="font-semibold text-text">{formatCurrency(entry.actualAmount || entry.plannedAmount, currencyCode)}</div>
<div className="text-xs text-muted">Plan {formatCurrency(entry.plannedAmount, currencyCode)}</div>
</div>
</div>
</button>
))
)}
</div>
</article>
</div>
<div className="surface-panel text-sm text-muted">
{status}
</div>
</section>
);
}

View File

@@ -1,188 +0,0 @@
import { useEffect, useState } from "react";
import { Gantt } from "@svar-ui/react-gantt";
import "@svar-ui/react-gantt/style.css";
import { Link } from "react-router-dom";
import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
import { useAuth } from "../../auth/AuthProvider";
import { ApiError, api } from "../../lib/api";
import { useTheme } from "../../theme/ThemeProvider";
function formatDate(value: string | null) {
if (!value) {
return "Unscheduled";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(new Date(value));
}
export function GanttPage() {
const { token } = useAuth();
const { mode } = useTheme();
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [status, setStatus] = useState("Loading live planning timeline...");
useEffect(() => {
if (!token) {
return;
}
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
.then(([data, rollup]) => {
setTimeline(data);
setPlanningRollup(rollup);
setStatus("Planning timeline loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
setStatus(message);
});
}, [token]);
const tasks = timeline?.tasks ?? [];
const links = timeline?.links ?? [];
const summary = timeline?.summary;
const exceptions = timeline?.exceptions ?? [];
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">Planning</p>
<h3 className="mt-2 text-2xl font-bold text-text">Live Project + Manufacturing Gantt</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
The planning surface now reads directly from active projects and open work orders so schedule pressure, due-date risk, and standalone manufacturing load are visible in one place.
</p>
</div>
<div className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Status</div>
<div className="mt-2 font-semibold text-text">{status}</div>
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-6">
<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">Active Projects</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeProjects ?? 0}</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">At Risk</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.atRiskProjects ?? 0}</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">Overdue Projects</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueProjects ?? 0}</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">Active Work Orders</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeWorkOrders ?? 0}</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">Overdue Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueWorkOrders ?? 0}</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">Unscheduled Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</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">Shortage Items</p>
<div className="mt-2 text-xl font-extrabold text-text">{planningRollup?.summary.uncoveredItemCount ?? 0}</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">Build / Buy</p>
<div className="mt-2 text-xl font-extrabold text-text">
{planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"}
</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div
className={`gantt-theme overflow-hidden rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
}`}
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
<p className="mt-2 text-sm text-muted">
{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}
</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
{tasks.length} schedule rows
</div>
</div>
<Gantt
tasks={tasks.map((task: GanttTaskDto) => ({
...task,
start: new Date(task.start),
end: new Date(task.end),
parent: task.parentId ?? undefined,
}))}
links={links}
/>
</div>
<aside className="space-y-3">
<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">Planning Exceptions</p>
<p className="mt-2 text-sm text-muted">Priority schedule issues from live project due dates and manufacturing commitments.</p>
{exceptions.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 planning exceptions are active.
</div>
) : (
<div className="mt-5 space-y-3">
{exceptions.map((exception: PlanningExceptionDto) => (
<Link key={exception.id} to={exception.detailHref} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
<div className="mt-1 font-semibold text-text">{exception.title}</div>
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
</div>
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
{exception.status.replaceAll("_", " ")}
</span>
</div>
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
</Link>
))}
</div>
)}
</section>
<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">Planner Actions</p>
<div className="mt-4 space-y-2 rounded-3xl border border-line/70 bg-page/60 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Uncovered quantity</span>
<span className="font-semibold text-text">{planningRollup?.summary.totalUncoveredQuantity ?? 0}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Projects with linked demand</span>
<span className="font-semibold text-text">{planningRollup?.summary.projectCount ?? 0}</span>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open projects
</Link>
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open work orders
</Link>
<Link to="/" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to dashboard
</Link>
</div>
</section>
</aside>
</div>
</section>
);
}

View File

@@ -5,13 +5,15 @@ import type {
InventoryTransferInput, InventoryTransferInput,
WarehouseLocationOptionDto, WarehouseLocationOptionDto,
} from "@mrp/shared/dist/inventory/types.js"; } from "@mrp/shared/dist/inventory/types.js";
import type { FileAttachmentDto } from "@mrp/shared";
import { permissions } from "@mrp/shared"; import { permissions } 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";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { emptyInventoryTransactionInput, inventoryThumbnailOwnerType, inventoryTransactionOptions } from "./config";
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel"; import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
import { InventoryStatusBadge } from "./InventoryStatusBadge"; import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge"; import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
@@ -48,8 +50,34 @@ export function InventoryDetailPage() {
const [isSavingTransfer, setIsSavingTransfer] = useState(false); const [isSavingTransfer, setIsSavingTransfer] = useState(false);
const [isSavingReservation, setIsSavingReservation] = useState(false); const [isSavingReservation, setIsSavingReservation] = useState(false);
const [status, setStatus] = useState("Loading inventory item..."); const [status, setStatus] = useState("Loading inventory item...");
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "transaction" | "transfer" | "reservation";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false; const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
function replaceThumbnailPreview(nextUrl: string | null) {
setThumbnailPreviewUrl((current) => {
if (current) {
window.URL.revokeObjectURL(current);
}
return nextUrl;
});
}
useEffect(() => { useEffect(() => {
if (!token || !itemId) { if (!token || !itemId) {
@@ -92,6 +120,56 @@ export function InventoryDetailPage() {
.catch(() => setLocationOptions([])); .catch(() => setLocationOptions([]));
}, [itemId, token]); }, [itemId, token]);
useEffect(() => {
return () => {
if (thumbnailPreviewUrl) {
window.URL.revokeObjectURL(thumbnailPreviewUrl);
}
};
}, [thumbnailPreviewUrl]);
useEffect(() => {
if (!token || !itemId || !canReadFiles) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
return;
}
let cancelled = false;
const activeToken: string = token;
const activeItemId: string = itemId;
async function loadThumbnail() {
const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId);
const latestAttachment = attachments[0] ?? null;
if (!latestAttachment) {
if (!cancelled) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
}
return;
}
const blob = await api.getFileContentBlob(activeToken, latestAttachment.id);
if (!cancelled) {
setThumbnailAttachment(latestAttachment);
replaceThumbnailPreview(window.URL.createObjectURL(blob));
}
}
void loadThumbnail().catch(() => {
if (!cancelled) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
}
});
return () => {
cancelled = true;
};
}, [canReadFiles, itemId, token]);
function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) { function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) {
setTransactionForm((current) => ({ ...current, [key]: value })); setTransactionForm((current) => ({ ...current, [key]: value }));
} }
@@ -100,8 +178,7 @@ export function InventoryDetailPage() {
setTransferForm((current) => ({ ...current, [key]: value })); setTransferForm((current) => ({ ...current, [key]: value }));
} }
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) { async function submitTransaction() {
event.preventDefault();
if (!token || !itemId) { if (!token || !itemId) {
return; return;
} }
@@ -112,7 +189,7 @@ export function InventoryDetailPage() {
try { try {
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm); const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
setItem(nextItem); setItem(nextItem);
setTransactionStatus("Stock transaction recorded."); setTransactionStatus("Stock transaction recorded. If this was posted in error, create an offsetting stock entry and verify the result in Recent Movements.");
setTransactionForm((current) => ({ setTransactionForm((current) => ({
...emptyInventoryTransactionInput, ...emptyInventoryTransactionInput,
transactionType: current.transactionType, transactionType: current.transactionType,
@@ -127,8 +204,7 @@ export function InventoryDetailPage() {
} }
} }
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) { async function submitTransfer() {
event.preventDefault();
if (!token || !itemId) { if (!token || !itemId) {
return; return;
} }
@@ -139,7 +215,7 @@ export function InventoryDetailPage() {
try { try {
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm); const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
setItem(nextItem); setItem(nextItem);
setTransferStatus("Transfer recorded."); setTransferStatus("Transfer recorded. Review stock balances on both locations and post a return transfer if this movement was entered incorrectly.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save transfer."; const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
setTransferStatus(message); setTransferStatus(message);
@@ -148,8 +224,7 @@ export function InventoryDetailPage() {
} }
} }
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) { async function submitReservation() {
event.preventDefault();
if (!token || !itemId) { if (!token || !itemId) {
return; return;
} }
@@ -160,7 +235,7 @@ export function InventoryDetailPage() {
try { try {
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm); const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
setItem(nextItem); setItem(nextItem);
setReservationStatus("Reservation recorded."); setReservationStatus("Reservation recorded. Verify available stock and add a compensating reservation change if this demand hold was entered incorrectly.");
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" })); setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save reservation."; const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
@@ -170,23 +245,81 @@ export function InventoryDetailPage() {
} }
} }
function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
const transactionLabel = inventoryTransactionOptions.find((option) => option.value === transactionForm.transactionType)?.label ?? "transaction";
setPendingConfirmation({
kind: "transaction",
title: `Post ${transactionLabel.toLowerCase()}`,
description: `Post a ${transactionLabel.toLowerCase()} of ${transactionForm.quantity} units for ${item.sku} at the selected stock location.`,
impact:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? "This reduces available inventory immediately and affects downstream shortage and readiness calculations."
: "This updates the stock ledger immediately and becomes part of the item transaction history.",
recovery: "If this is incorrect, post an explicit offsetting transaction instead of editing history.",
confirmLabel: `Post ${transactionLabel.toLowerCase()}`,
confirmationLabel:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? "Type item SKU to confirm:"
: undefined,
confirmationValue:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? item.sku
: undefined,
});
}
function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
setPendingConfirmation({
kind: "transfer",
title: "Post inventory transfer",
description: `Move ${transferForm.quantity} units of ${item.sku} between the selected source and destination locations.`,
impact: "This creates paired stock movement entries and changes both source and destination availability immediately.",
recovery: "If the move was entered incorrectly, post a reversing transfer back to the original location.",
confirmLabel: "Post transfer",
});
}
function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
setPendingConfirmation({
kind: "reservation",
title: "Create manual reservation",
description: `Reserve ${reservationForm.quantity} units of ${item.sku}${reservationForm.locationId ? " at the selected location" : ""}.`,
impact: "This reduces available quantity used by planning, purchasing, manufacturing, and readiness views.",
recovery: "Add the correcting reservation entry if this hold should be reduced or removed.",
confirmLabel: "Create reservation",
});
}
if (!item) { if (!item) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Detail</p> <p className="section-kicker">INVENTORY DETAIL</p>
<h3 className="mt-2 text-xl font-bold text-text">{item.sku}</h3> <h3 className="module-title">{item.sku}</h3>
<p className="mt-1 text-sm text-text">{item.name}</p> <p className="mt-1 text-sm text-text">{item.name}</p>
<div className="mt-4 flex flex-wrap gap-3"> <div className="mt-2.5 flex flex-wrap gap-2">
<InventoryTypeBadge type={item.type} /> <InventoryTypeBadge type={item.type} />
<InventoryStatusBadge status={item.status} /> <InventoryStatusBadge status={item.status} />
</div> </div>
<p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p> <p className="mt-2 text-sm text-muted">UPDATED {new Date(item.updatedAt).toLocaleString()}</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
@@ -201,40 +334,40 @@ export function InventoryDetailPage() {
</div> </div>
</div> </div>
<section className="grid gap-3 xl:grid-cols-7"> <section className="grid gap-2 xl:grid-cols-7">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p>
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div> <div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reserved</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reserved</p>
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</div> <div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Available</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Available</p>
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</div> <div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Stock Locations</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Stock Locations</p>
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div> <div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transactions</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transactions</p>
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div> <div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p>
<div className="mt-2 text-base font-bold text-text">{item.transfers.length}</div> <div className="mt-2 text-base font-bold text-text">{item.transfers.length}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p>
<div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div> <div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
</article> </article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p> <p className="section-kicker">ITEM DEFINITION</p>
<dl className="mt-5 grid gap-3 xl:grid-cols-2"> <dl className="mt-5 grid gap-3 xl:grid-cols-2">
<div> <div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
@@ -264,12 +397,30 @@ export function InventoryDetailPage() {
</div> </div>
</dl> </dl>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock By Location</p> <p className="section-kicker">THUMBNAIL</p>
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
{thumbnailPreviewUrl ? (
<img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" />
) : (
<div className="flex aspect-square items-center justify-center px-4 text-center text-sm text-muted">
No thumbnail image has been attached to this item.
</div>
)}
</div>
<div className="mt-2 text-xs text-muted">
{thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."}
</div>
</article>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
<article className="surface-panel">
<p className="section-kicker">STOCK BY LOCATION</p>
{item.stockBalances.length === 0 ? ( {item.stockBalances.length === 0 ? (
<p className="mt-4 text-sm text-muted">No stock or reservation balances have been posted for this item yet.</p> <p className="mt-3 text-sm text-muted">No stock or reservation balances posted yet.</p>
) : ( ) : (
<div className="mt-4 space-y-2"> <div className="mt-3 space-y-2">
{item.stockBalances.map((balance) => ( {item.stockBalances.map((balance) => (
<div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="min-w-0"> <div className="min-w-0">
@@ -293,9 +444,9 @@ export function InventoryDetailPage() {
<section className="grid gap-3 xl:grid-cols-2"> <section className="grid gap-3 xl:grid-cols-2">
{canManage ? ( {canManage ? (
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}> <form className="surface-panel" onSubmit={handleTransactionSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p> <p className="section-kicker">STOCK TRANSACTIONS</p>
<div className="mt-5 grid gap-3"> <div className="mt-3 grid gap-3">
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span> <span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
@@ -334,7 +485,7 @@ export function InventoryDetailPage() {
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("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" /> <textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2"> <div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{transactionStatus}</span> <span className="text-sm text-muted">{transactionStatus}</span>
@@ -345,16 +496,16 @@ export function InventoryDetailPage() {
</div> </div>
</form> </form>
) : null} ) : null}
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Movements</p> <p className="section-kicker">RECENT MOVEMENTS</p>
{item.recentTransactions.length === 0 ? ( {item.recentTransactions.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No stock transactions have been recorded for this item yet. No stock transactions recorded yet.
</div> </div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{item.recentTransactions.map((transaction) => ( {item.recentTransactions.map((transaction) => (
<article key={transaction.id} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <article key={transaction.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -384,9 +535,9 @@ export function InventoryDetailPage() {
{canManage ? ( {canManage ? (
<section className="grid gap-3 xl:grid-cols-2"> <section className="grid gap-3 xl:grid-cols-2">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}> <form className="surface-panel" onSubmit={handleTransferSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p> <p className="section-kicker">INVENTORY TRANSFER</p>
<div className="mt-5 grid gap-3"> <div className="mt-3 grid gap-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span> <span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
@@ -427,7 +578,7 @@ export function InventoryDetailPage() {
</div> </div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={transferForm.notes} onChange={(event) => updateTransferField("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" /> <textarea value={transferForm.notes} onChange={(event) => updateTransferField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2"> <div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{transferStatus}</span> <span className="text-sm text-muted">{transferStatus}</span>
@@ -437,9 +588,9 @@ export function InventoryDetailPage() {
</div> </div>
</div> </div>
</form> </form>
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}> <form className="surface-panel" onSubmit={handleReservationSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p> <p className="section-kicker">MANUAL RESERVATION</p>
<div className="mt-5 grid gap-3"> <div className="mt-3 grid gap-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span> <span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
@@ -464,7 +615,7 @@ export function InventoryDetailPage() {
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, 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" /> <textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2"> <div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{reservationStatus}</span> <span className="text-sm text-muted">{reservationStatus}</span>
@@ -478,16 +629,16 @@ export function InventoryDetailPage() {
) : null} ) : null}
<section className="grid gap-3 xl:grid-cols-2"> <section className="grid gap-3 xl:grid-cols-2">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Reservations</p> <p className="section-kicker">RESERVATIONS</p>
{item.reservations.length === 0 ? ( {item.reservations.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No reservations have been recorded for this item. No reservations recorded.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{item.reservations.map((reservation) => ( {item.reservations.map((reservation) => (
<article key={reservation.id} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <article key={reservation.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">{reservation.quantity} reserved</div> <div className="font-semibold text-text">{reservation.quantity} reserved</div>
@@ -504,16 +655,16 @@ export function InventoryDetailPage() {
</div> </div>
)} )}
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Transfers</p> <p className="section-kicker">TRANSFERS</p>
{item.transfers.length === 0 ? ( {item.transfers.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No transfers have been recorded for this item. No transfers recorded.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{item.transfers.map((transfer) => ( {item.transfers.map((transfer) => (
<article key={transfer.id} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <article key={transfer.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="font-semibold text-text">{transfer.quantity} moved</div> <div className="font-semibold text-text">{transfer.quantity} moved</div>
<div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div> <div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div>
@@ -530,6 +681,42 @@ export function InventoryDetailPage() {
</section> </section>
<InventoryAttachmentsPanel itemId={item.id} /> <InventoryAttachmentsPanel itemId={item.id} />
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm inventory action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "transaction" && isSavingTransaction) ||
(pendingConfirmation?.kind === "transfer" && isSavingTransfer) ||
(pendingConfirmation?.kind === "reservation" && isSavingReservation)
}
onClose={() => {
if (!isSavingTransaction && !isSavingTransfer && !isSavingReservation) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "transaction") {
await submitTransaction();
} else if (pendingConfirmation.kind === "transfer") {
await submitTransfer();
} else {
await submitReservation();
}
setPendingConfirmation(null);
}}
/>
</section> </section>
); );
} }

View File

@@ -1,12 +1,22 @@
import type { PurchaseVendorOptionDto } from "@mrp/shared"; import type { FileAttachmentDto, PurchaseVendorOptionDto } from "@mrp/shared";
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js"; import { permissions } from "@mrp/shared";
import type {
InventoryBomLineInput,
InventoryItemInput,
InventoryItemOperationInput,
InventoryItemOptionDto,
InventorySkuBuilderPreviewDto,
InventorySkuFamilyDto,
InventorySkuNodeDto,
} from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared"; import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config"; import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryThumbnailOwnerType, inventoryTypeOptions, inventoryUnitOptions } from "./config";
interface InventoryFormPageProps { interface InventoryFormPageProps {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -14,7 +24,7 @@ interface InventoryFormPageProps {
export function InventoryFormPage({ mode }: InventoryFormPageProps) { export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth(); const { token, user } = useAuth();
const { itemId } = useParams(); const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput); const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]); const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
@@ -26,6 +36,15 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [vendorPickerOpen, setVendorPickerOpen] = useState(false); const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item..."); const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [pendingRemoval, setPendingRemoval] = useState<{ kind: "operation" | "bom-line"; index: number } | null>(null);
const [skuFamilies, setSkuFamilies] = useState<InventorySkuFamilyDto[]>([]);
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]);
const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(null);
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
const [pendingThumbnailFile, setPendingThumbnailFile] = useState<File | null>(null);
const [removeThumbnailOnSave, setRemoveThumbnailOnSave] = useState(false);
function getComponentOption(componentItemId: string) { function getComponentOption(componentItemId: string) {
return componentOptions.find((option) => option.id === componentItemId) ?? null; return componentOptions.find((option) => option.id === componentItemId) ?? null;
@@ -35,6 +54,27 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
return getComponentOption(componentItemId)?.sku ?? ""; return getComponentOption(componentItemId)?.sku ?? "";
} }
function getSkuFamilyName(familyId: string | null) {
if (!familyId) {
return "";
}
return skuFamilies.find((family) => family.id === familyId)?.name ?? "";
}
function replaceThumbnailPreview(nextUrl: string | null) {
setThumbnailPreviewUrl((current) => {
if (current) {
window.URL.revokeObjectURL(current);
}
return nextUrl;
});
}
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
return; return;
@@ -69,6 +109,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
.then((item) => { .then((item) => {
setForm({ setForm({
sku: item.sku, sku: item.sku,
skuBuilder: item.skuBuilder ? { familyId: item.skuBuilder.familyId, nodeId: item.skuBuilder.nodeId } : null,
name: item.name, name: item.name,
description: item.description, description: item.description,
type: item.type, type: item.type,
@@ -96,6 +137,8 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
notes: operation.notes, notes: operation.notes,
})), })),
}); });
setSelectedSkuNodeIds(item.skuBuilder?.nodePath.map((entry) => entry.id) ?? []);
setSkuPreview(item.skuBuilder ? { ...item.skuBuilder, nextSequenceNumber: item.skuBuilder.sequenceNumber ?? 0, availableLevels: Math.max(0, 6 - item.skuBuilder.segments.length), hasChildren: false } : null);
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku)); setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded."); setStatus("Inventory item loaded.");
setVendorSearchTerm(item.preferredVendorName ?? ""); setVendorSearchTerm(item.preferredVendorName ?? "");
@@ -113,12 +156,154 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
api.getManufacturingStations(token).then(setStations).catch(() => setStations([])); api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([])); api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([]));
}, [token]); }, [token]);
useEffect(() => {
return () => {
if (thumbnailPreviewUrl) {
window.URL.revokeObjectURL(thumbnailPreviewUrl);
}
};
}, [thumbnailPreviewUrl]);
useEffect(() => {
if (!token || !itemId || !canReadFiles) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
return;
}
let cancelled = false;
const activeToken: string = token;
const activeItemId: string = itemId;
async function loadThumbnail() {
const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId);
const latestAttachment = attachments[0] ?? null;
if (!latestAttachment) {
if (!cancelled) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
}
return;
}
const blob = await api.getFileContentBlob(activeToken, latestAttachment.id);
if (!cancelled) {
setThumbnailAttachment(latestAttachment);
replaceThumbnailPreview(window.URL.createObjectURL(blob));
}
}
void loadThumbnail().catch(() => {
if (!cancelled) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
}
});
return () => {
cancelled = true;
};
}, [canReadFiles, itemId, token]);
useEffect(() => {
const familyId = form.skuBuilder?.familyId ?? null;
if (!token || !familyId) {
setSkuLevelOptions([]);
setSelectedSkuNodeIds([]);
setSkuPreview(null);
return;
}
let cancelled = false;
const activeFamilyId: string = familyId;
const activeToken: string = token;
async function loadSkuBuilderState() {
const nextOptions: InventorySkuNodeDto[][] = [];
let parentNodeId: string | null = null;
const lineage = selectedSkuNodeIds.filter((value): value is string => Boolean(value));
for (let levelIndex = 0; levelIndex <= lineage.length; levelIndex += 1) {
const options = await api.getInventorySkuNodes(activeToken, activeFamilyId, parentNodeId ?? undefined);
if (!options.length) {
break;
}
nextOptions.push(options);
const nextSelectedNodeId = lineage[levelIndex];
if (!nextSelectedNodeId || !options.some((option) => option.id === nextSelectedNodeId)) {
break;
}
parentNodeId = nextSelectedNodeId;
}
const leafNodeId = lineage.length > 0 ? lineage[lineage.length - 1] : null;
const preview = await api.getInventorySkuPreview(activeToken, activeFamilyId, leafNodeId ?? undefined);
if (!cancelled) {
setSkuLevelOptions(nextOptions);
setSkuPreview(preview);
setForm((current) => ({
...current,
sku: preview.generatedSku,
skuBuilder: current.skuBuilder ? { familyId: current.skuBuilder.familyId, nodeId: leafNodeId ?? null } : current.skuBuilder,
}));
}
}
void loadSkuBuilderState().catch(() => {
if (!cancelled) {
setSkuLevelOptions([]);
setSkuPreview(null);
}
});
return () => {
cancelled = true;
};
}, [form.skuBuilder?.familyId, selectedSkuNodeIds, token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) { function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
} }
function updateSkuFamily(familyId: string) {
if (!familyId) {
setSelectedSkuNodeIds([]);
setSkuLevelOptions([]);
setSkuPreview(null);
setForm((current) => ({
...current,
skuBuilder: null,
sku: mode === "edit" && !current.skuBuilder ? current.sku : "",
}));
return;
}
setSelectedSkuNodeIds([]);
setForm((current) => ({
...current,
skuBuilder: {
familyId,
nodeId: null,
},
}));
}
function updateSkuNode(levelIndex: number, nodeId: string) {
setSelectedSkuNodeIds((current) => {
const next = current.slice(0, levelIndex);
if (nodeId) {
next[levelIndex] = nodeId;
}
return next;
});
}
function getSelectedVendorName(vendorId: string | null) { function getSelectedVendorName(vendorId: string | null) {
if (!vendorId) { if (!vendorId) {
return ""; return "";
@@ -192,6 +377,52 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current)); setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current));
} }
function handleThumbnailSelect(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
setPendingThumbnailFile(file);
setRemoveThumbnailOnSave(false);
replaceThumbnailPreview(window.URL.createObjectURL(file));
event.target.value = "";
}
function clearThumbnailSelection() {
setPendingThumbnailFile(null);
setRemoveThumbnailOnSave(true);
replaceThumbnailPreview(null);
}
async function syncThumbnail(savedItemId: string) {
if (!token || !canWriteFiles) {
return;
}
if (thumbnailAttachment && (removeThumbnailOnSave || pendingThumbnailFile)) {
await api.deleteAttachment(token, thumbnailAttachment.id);
}
if (pendingThumbnailFile) {
const uploaded = await api.uploadFile(token, pendingThumbnailFile, inventoryThumbnailOwnerType, savedItemId);
setThumbnailAttachment(uploaded);
const blob = await api.getFileContentBlob(token, uploaded.id);
replaceThumbnailPreview(window.URL.createObjectURL(blob));
} else if (removeThumbnailOnSave) {
setThumbnailAttachment(null);
}
setPendingThumbnailFile(null);
setRemoveThumbnailOnSave(false);
}
const pendingRemovalDetail = pendingRemoval
? pendingRemoval.kind === "operation"
? { label: form.operations[pendingRemoval.index]?.stationId || "this routing operation", typeLabel: "routing operation" }
: { label: getComponentSku(form.bomLines[pendingRemoval.index]?.componentItemId ?? "") || "this BOM line", typeLabel: "BOM line" }
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -204,6 +435,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
try { try {
const saved = const saved =
mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form); mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form);
await syncThumbnail(saved.id);
navigate(`/inventory/items/${saved.id}`); navigate(`/inventory/items/${saved.id}`);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save inventory item."; const message = error instanceof ApiError ? error.message : "Unable to save inventory item.";
@@ -212,35 +444,108 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
} }
} }
function forceNavigate(path: string) {
window.location.assign(path);
}
function openSkuMaster() {
forceNavigate("/inventory/sku-master");
}
function closeEditor() {
forceNavigate(mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`);
}
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Editor</p> <p className="section-kicker">INVENTORY EDITOR</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Item" : "Edit Item"}</h3> <h3 className="module-title">{mode === "create" ? "NEW ITEM" : "EDIT ITEM"}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted"> </div>
Define item master data and the first revision of the bill of materials for assemblies and manufactured items. <div className="flex flex-wrap gap-2">
</p> <button
type="button"
onClick={openSkuMaster}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
SKU master
</button>
<button
type="button"
onClick={closeEditor}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Cancel
</button>
</div> </div>
<Link
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
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> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel space-y-3">
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
<label className="block"> <div className="block 2xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">SKU</span> <div className="mb-2 flex items-center justify-between gap-2">
<input <span className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">SKU BUILDER</span>
value={form.sku} <button type="button" onClick={openSkuMaster} className="text-xs font-semibold text-brand">
onChange={(event) => updateField("sku", event.target.value)} Manage SKU tree
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" </button>
/> </div>
</label> <div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
<select
value={form.skuBuilder?.familyId ?? ""}
onChange={(event) => updateSkuFamily(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"
>
<option value="">Select family</option>
{skuFamilies.filter((family) => family.isActive).map((family) => (
<option key={family.id} value={family.id}>
{family.code} - {family.name}
</option>
))}
</select>
</label>
{skuLevelOptions.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{skuLevelOptions.map((options, levelIndex) => (
<label key={`sku-level-${levelIndex}`} className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level {levelIndex + 2}</span>
<select
value={selectedSkuNodeIds[levelIndex] ?? ""}
onChange={(event) => updateSkuNode(levelIndex, 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"
>
<option value="">Stop at this level</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.code} - {option.label}
</option>
))}
</select>
</label>
))}
</div>
) : null}
<div className="rounded-2xl border border-line/70 bg-surface px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Generated SKU</div>
<div className="mt-2 text-lg font-bold text-text">{skuPreview?.generatedSku || form.sku || "Select a family to generate SKU"}</div>
<div className="mt-2 text-xs text-muted">
{skuPreview
? `${skuPreview.familyCode} branch with ${skuPreview.sequenceCode}${String(skuPreview.nextSequenceNumber).padStart(4, "0")} next in sequence.`
: form.skuBuilder?.familyId
? `Building from ${getSkuFamilyName(form.skuBuilder.familyId)}.`
: "SKU suffix is family-scoped and assigned by the server when the item is saved."}
</div>
</div>
<input
value={form.sku}
readOnly
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none opacity-80"
/>
</div>
</div>
<label className="block 2xl:col-span-2"> <label className="block 2xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Item name</span> <span className="mb-2 block text-sm font-semibold text-text">Item name</span>
<input <input
@@ -272,6 +577,45 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
/> />
</label> </label>
</div> </div>
<div className="grid gap-4 xl:grid-cols-[220px_1fr]">
<div className="space-y-3">
<div>
<span className="mb-2 block text-sm font-semibold text-text">Thumbnail</span>
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
{thumbnailPreviewUrl ? (
<img src={thumbnailPreviewUrl} alt="Inventory thumbnail preview" className="h-full w-full object-cover" />
) : (
<div className="px-4 text-center text-xs text-muted">No thumbnail selected</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
Choose image
<input className="hidden" type="file" accept="image/*" onChange={handleThumbnailSelect} />
</label>
) : null}
{(thumbnailPreviewUrl || thumbnailAttachment) && canWriteFiles ? (
<button type="button" onClick={clearThumbnailSelection} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Remove
</button>
) : null}
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Thumbnail attachment</div>
<div className="mt-2 text-sm text-muted">
{pendingThumbnailFile
? `${pendingThumbnailFile.name} will upload when you save this item.`
: removeThumbnailOnSave
? "Thumbnail removal is staged and will apply when you save."
: thumbnailAttachment
? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.`
: "Attach a product image, render, or reference photo for this item."}
</div>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-4">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Type</span> <span className="mb-2 block text-sm font-semibold text-text">Type</span>
@@ -318,7 +662,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2"> <label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input type="checkbox" checked={form.isSellable} onChange={(event) => updateField("isSellable", event.target.checked)} /> <input type="checkbox" checked={form.isSellable} onChange={(event) => updateField("isSellable", event.target.checked)} />
<span className="text-sm font-semibold text-text">Sellable</span> <span className="text-sm font-semibold uppercase tracking-[0.08em] text-text">Sellable</span>
</label> </label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2"> <label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input <input
@@ -326,7 +670,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
checked={form.isPurchasable} checked={form.isPurchasable}
onChange={(event) => updateField("isPurchasable", event.target.checked)} onChange={(event) => updateField("isPurchasable", event.target.checked)}
/> />
<span className="text-sm font-semibold text-text">Purchasable</span> <span className="text-sm font-semibold uppercase tracking-[0.08em] text-text">Purchasable</span>
</label> </label>
</div> </div>
</div> </div>
@@ -397,7 +741,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
) : null} ) : null}
</div> </div>
<div className="mt-2 text-xs text-muted"> <div className="mt-2 text-xs text-muted">
{form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Demand planning uses this vendor when creating buy recommendations."} {form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Used as the default buy source."}
</div> </div>
</label> </label>
<label className="block"> <label className="block">
@@ -406,7 +750,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
value={form.description} value={form.description}
onChange={(event) => updateField("description", event.target.value)} onChange={(event) => updateField("description", event.target.value)}
rows={4} rows={4}
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/> />
</label> </label>
<label className="block"> <label className="block">
@@ -415,30 +759,29 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
value={form.notes} value={form.notes}
onChange={(event) => updateField("notes", event.target.value)} onChange={(event) => updateField("notes", event.target.value)}
rows={4} rows={4}
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/> />
</label> </label>
</section> </section>
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? ( {form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Routing</p> <p className="section-kicker">MANUFACTURING ROUTING</p>
<h4 className="mt-2 text-lg font-bold text-text">Station and time template</h4> <h4 className="text-lg font-bold text-text">STATION AND TIME TEMPLATE</h4>
<p className="mt-2 text-sm text-muted">These operations are copied automatically into work orders and drive gantt scheduling without manual planner task entry.</p>
</div> </div>
<button type="button" onClick={addOperation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <button type="button" onClick={addOperation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add operation Add operation
</button> </button>
</div> </div>
{form.operations.length === 0 ? ( {form.operations.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
Add at least one station operation for this buildable item. Add at least one station operation for this buildable item.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-4"> <div className="mt-3 space-y-3">
{form.operations.map((operation, index) => ( {form.operations.map((operation, index) => (
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]"> <div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Station</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Station</span>
@@ -472,7 +815,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<input type="number" min={0} step={10} value={operation.position} onChange={(event) => updateOperation(index, { ...operation, position: Number(event.target.value) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" /> <input type="number" min={0} step={10} value={operation.position} onChange={(event) => updateOperation(index, { ...operation, position: Number(event.target.value) || 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>
<div className="flex items-end"> <div className="flex items-end">
<button type="button" onClick={() => removeOperation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300"> <button type="button" onClick={() => setPendingRemoval({ kind: "operation", 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 Remove
</button> </button>
</div> </div>
@@ -487,12 +830,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
)} )}
</section> </section>
) : null} ) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Bill Of Materials</p> <p className="section-kicker">BILL OF MATERIALS</p>
<h4 className="mt-2 text-lg font-bold text-text">Component lines</h4> <h4 className="text-lg font-bold text-text">COMPONENT LINES</h4>
<p className="mt-2 text-sm text-muted">Add BOM components for manufactured or assembly items. Purchased and service items can be saved without BOM lines.</p>
</div> </div>
<button <button
type="button" type="button"
@@ -503,13 +845,13 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</button> </button>
</div> </div>
{form.bomLines.length === 0 ? ( {form.bomLines.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No BOM lines added yet. No BOM lines added yet.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-4"> <div className="mt-3 space-y-3">
{form.bomLines.map((line, index) => ( {form.bomLines.map((line, index) => (
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]"> <div className="grid gap-3 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
@@ -619,7 +961,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<div className="flex items-end"> <div className="flex items-end">
<button <button
type="button" type="button"
onClick={() => removeBomLine(index)} onClick={() => setPendingRemoval({ kind: "bom-line", index })}
className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300" className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300"
> >
Remove Remove
@@ -638,7 +980,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
))} ))}
</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"> <div className="mt-4 flex flex-col gap-2 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> <span className="min-w-0 text-sm text-muted">{status}</span>
<button <button
type="submit" type="submit"
@@ -649,6 +991,32 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</button> </button>
</div> </div>
</section> </section>
<ConfirmActionDialog
open={pendingRemoval != null}
title={pendingRemoval?.kind === "operation" ? "Remove routing operation" : "Remove BOM line"}
description={
pendingRemoval && pendingRemovalDetail
? `Remove ${pendingRemovalDetail.label} from the item ${pendingRemovalDetail.typeLabel} draft.`
: "Remove this draft row."
}
impact={
pendingRemoval?.kind === "operation"
? "The operation will no longer be copied into new work orders from this item."
: "The component requirement will be removed from the BOM draft immediately."
}
recovery="Add the row back before saving if this change was accidental."
confirmLabel={pendingRemoval?.kind === "operation" ? "Remove operation" : "Remove BOM line"}
onClose={() => setPendingRemoval(null)}
onConfirm={() => {
if (pendingRemoval?.kind === "operation") {
removeOperation(pendingRemoval.index);
} else if (pendingRemoval?.kind === "bom-line") {
removeBomLine(pendingRemoval.index);
}
setPendingRemoval(null);
}}
/>
</form> </form>
); );
} }

View File

@@ -41,22 +41,24 @@ export function InventoryListPage() {
}, [searchTerm, statusFilter, token, typeFilter]); }, [searchTerm, statusFilter, token, typeFilter]);
return ( return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p> <p className="section-kicker">INVENTORY</p>
<h3 className="mt-2 text-lg font-bold text-text">Item Master</h3> <h3 className="module-title">ITEM MASTER</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Core item and BOM definitions for purchased parts, manufactured items, assemblies, and service SKUs.
</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white"> <div className="flex flex-wrap gap-2">
New item <Link to="/inventory/sku-master" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
</Link> SKU master
</Link>
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New item
</Link>
</div>
) : null} ) : null}
</div> </div>
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.3fr_0.8fr_0.8fr]"> <div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input <input
@@ -95,13 +97,13 @@ export function InventoryListPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
{items.length === 0 ? ( {items.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"> <div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
No inventory items have been added yet. No inventory items have been added yet.
</div> </div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
@@ -109,7 +111,7 @@ export function InventoryListPage() {
<th className="px-2 py-2">Type</th> <th className="px-2 py-2">Type</th>
<th className="px-2 py-2">Status</th> <th className="px-2 py-2">Status</th>
<th className="px-2 py-2">UOM</th> <th className="px-2 py-2">UOM</th>
<th className="px-2 py-2">Flags</th> <th className="px-2 py-2">Qty</th>
<th className="px-2 py-2">BOM</th> <th className="px-2 py-2">BOM</th>
<th className="px-2 py-2">Updated</th> <th className="px-2 py-2">Updated</th>
</tr> </tr>
@@ -131,8 +133,8 @@ export function InventoryListPage() {
</td> </td>
<td className="px-2 py-2 text-muted">{item.unitOfMeasure}</td> <td className="px-2 py-2 text-muted">{item.unitOfMeasure}</td>
<td className="px-2 py-2 text-xs text-muted"> <td className="px-2 py-2 text-xs text-muted">
<div>{item.isSellable ? "Sellable" : "Not sellable"}</div> <div>Total {item.onHandQuantity}</div>
<div>{item.isPurchasable ? "Purchasable" : "Not purchasable"}</div> <div>Available {item.availableQuantity}</div>
</td> </td>
<td className="px-2 py-2 text-muted">{item.bomLineCount} lines</td> <td className="px-2 py-2 text-muted">{item.bomLineCount} lines</td>
<td className="px-2 py-2 text-muted">{new Date(item.updatedAt).toLocaleDateString()}</td> <td className="px-2 py-2 text-muted">{new Date(item.updatedAt).toLocaleDateString()}</td>
@@ -145,3 +147,4 @@ export function InventoryListPage() {
</section> </section>
); );
} }

View File

@@ -0,0 +1,342 @@
import { permissions } from "@mrp/shared";
import type { InventorySkuCatalogTreeDto, InventorySkuFamilyInput, InventorySkuNodeInput } from "@mrp/shared/dist/inventory/types.js";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
const emptyFamilyForm: InventorySkuFamilyInput = {
code: "",
sequenceCode: "",
name: "",
description: "",
isActive: true,
};
const emptyNodeForm: InventorySkuNodeInput = {
familyId: "",
parentNodeId: null,
code: "",
label: "",
description: "",
sortOrder: 10,
isActive: true,
};
export function InventorySkuMasterPage() {
const { token, user } = useAuth();
const [catalog, setCatalog] = useState<InventorySkuCatalogTreeDto>({ families: [], nodes: [] });
const [familyForm, setFamilyForm] = useState<InventorySkuFamilyInput>(emptyFamilyForm);
const [nodeForm, setNodeForm] = useState<InventorySkuNodeInput>(emptyNodeForm);
const [selectedFamilyId, setSelectedFamilyId] = useState("");
const [expandedNodeIds, setExpandedNodeIds] = useState<string[]>([]);
const [status, setStatus] = useState("Loading SKU master...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api
.getInventorySkuCatalog(token)
.then((nextCatalog) => {
setCatalog(nextCatalog);
const firstFamilyId = nextCatalog.families[0]?.id ?? "";
setSelectedFamilyId((current) => current || firstFamilyId);
setExpandedNodeIds([]);
setNodeForm((current) => ({
...current,
familyId: current.familyId || firstFamilyId,
}));
setStatus(`${nextCatalog.families.length} family branch(es) loaded.`);
})
.catch((error: unknown) => {
setStatus(error instanceof ApiError ? error.message : "Unable to load SKU master.");
});
}, [token]);
const familyNodes = useMemo(
() =>
catalog.nodes
.filter((node) => node.familyId === selectedFamilyId)
.sort((left, right) => left.level - right.level || left.sortOrder - right.sortOrder || left.code.localeCompare(right.code)),
[catalog.nodes, selectedFamilyId]
);
const parentOptions = useMemo(
() => familyNodes.filter((node) => node.level < 6),
[familyNodes]
);
function toggleNode(nodeId: string) {
setExpandedNodeIds((current) => (current.includes(nodeId) ? current.filter((id) => id !== nodeId) : [...current, nodeId]));
}
function renderNodes(parentNodeId: string | null, depth = 0): ReactNode {
const branchNodes = familyNodes.filter((node) => node.parentNodeId === parentNodeId);
if (!branchNodes.length) {
return null;
}
return branchNodes.map((node) => {
const isExpanded = expandedNodeIds.includes(node.id);
return (
<div key={node.id} className="space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2" style={{ marginLeft: `${depth * 16}px` }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{node.childCount > 0 ? (
<button
type="button"
onClick={() => toggleNode(node.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-xl border border-line/70 text-sm font-semibold text-text transition hover:bg-page/80"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.code}`}
>
{isExpanded ? "-" : "+"}
</button>
) : (
<span className="inline-flex h-7 w-7 items-center justify-center rounded-xl border border-dashed border-line/50 text-xs text-muted">
o
</span>
)}
<div className="min-w-0">
<div className="text-sm font-semibold text-text">
{node.code} <span className="text-muted">- {node.label}</span>
</div>
<div className="mt-1 text-xs text-muted">Level {node.level} - {node.childCount} child branch(es)</div>
</div>
</div>
</div>
<button
type="button"
onClick={() =>
setNodeForm((current) => ({
...current,
familyId: selectedFamilyId,
parentNodeId: node.id,
}))
}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text"
>
Add child
</button>
</div>
{node.description ? <div className="mt-2 text-xs text-muted">{node.description}</div> : null}
</div>
{isExpanded ? renderNodes(node.id, depth + 1) : null}
</div>
);
});
}
async function reloadCatalog() {
if (!token) {
return;
}
const nextCatalog = await api.getInventorySkuCatalog(token);
setCatalog(nextCatalog);
if (!selectedFamilyId && nextCatalog.families[0]) {
setSelectedFamilyId(nextCatalog.families[0].id);
}
}
async function handleCreateFamily(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !canManage) {
return;
}
try {
const created = await api.createInventorySkuFamily(token, familyForm);
setFamilyForm(emptyFamilyForm);
setSelectedFamilyId(created.id);
setExpandedNodeIds([]);
setNodeForm((current) => ({ ...current, familyId: created.id, parentNodeId: null }));
await reloadCatalog();
setStatus(`Created SKU family ${created.code}.`);
} catch (error: unknown) {
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU family.");
}
}
async function handleCreateNode(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !canManage || !nodeForm.familyId) {
return;
}
try {
const created = await api.createInventorySkuNode(token, nodeForm);
setExpandedNodeIds((current) => {
if (!created.parentNodeId || current.includes(created.parentNodeId)) {
return current;
}
return [...current, created.parentNodeId];
});
setNodeForm((current) => ({
...emptyNodeForm,
familyId: current.familyId,
parentNodeId: created.parentNodeId,
}));
await reloadCatalog();
setStatus(`Created SKU branch ${created.code}.`);
} catch (error: unknown) {
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU branch.");
}
}
return (
<section className="page-stack">
<div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="section-kicker">INVENTORY MASTER DATA</p>
<h3 className="module-title">SKU MASTER BUILDER</h3>
</div>
<Link to="/inventory/items" 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 items
</Link>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-[0.9fr_1.5fr]">
<div className="space-y-3">
<section className="surface-panel">
<p className="section-kicker">FAMILIES</p>
<div className="mt-3 space-y-2">
{catalog.families.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-sm text-muted">No SKU families defined yet.</div>
) : (
catalog.families.map((family) => (
<button
key={family.id}
type="button"
onClick={() => {
setSelectedFamilyId(family.id);
setExpandedNodeIds([]);
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
}}
className={`block w-full rounded-[18px] border px-2 py-2 text-left transition ${
selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
}`}
>
<div className="text-sm font-semibold text-text">
{family.code} <span className="text-muted">({family.sequenceCode})</span>
</div>
<div className="mt-1 text-xs text-muted">{family.name}</div>
<div className="mt-2 text-xs text-muted">
{family.childNodeCount} branch nodes - next {family.sequenceCode}
{String(family.nextSequenceNumber).padStart(4, "0")}
</div>
</button>
))
)}
</div>
</section>
{canManage ? (
<section className="surface-panel">
<p className="section-kicker">ADD FAMILY</p>
<form className="mt-3 space-y-3" onSubmit={handleCreateFamily}>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
<input value={familyForm.code} onChange={(event) => setFamilyForm((current) => ({ ...current, code: 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" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Suffix code</span>
<input value={familyForm.sequenceCode} onChange={(event) => setFamilyForm((current) => ({ ...current, sequenceCode: 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" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
<input value={familyForm.name} onChange={(event) => setFamilyForm((current) => ({ ...current, name: 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" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea value={familyForm.description} onChange={(event) => setFamilyForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">Create family</button>
</form>
</section>
) : null}
</div>
<div className="space-y-3">
<section className="surface-panel">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="section-kicker">BRANCH TREE</p>
<div className="mt-1 text-xs text-muted">{status}</div>
</div>
{selectedFamilyId ? (
<div className="text-xs text-muted">Up to 6 total SKU levels.</div>
) : null}
</div>
<div className="mt-3 space-y-2">
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>}
</div>
</section>
{canManage ? (
<section className="surface-panel">
<p className="section-kicker">ADD BRANCH NODE</p>
<form className="mt-3 space-y-3" onSubmit={handleCreateNode}>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
<select value={nodeForm.familyId} onChange={(event) => setNodeForm((current) => ({ ...current, familyId: event.target.value, parentNodeId: null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Select family</option>
{catalog.families.map((family) => (
<option key={family.id} value={family.id}>{family.code} - {family.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Parent branch</span>
<select value={nodeForm.parentNodeId ?? ""} onChange={(event) => setNodeForm((current) => ({ ...current, parentNodeId: 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">
<option value="">Family root</option>
{parentOptions.map((node) => (
<option key={node.id} value={node.id}>L{node.level} - {node.code} - {node.label}</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
<input value={nodeForm.code} onChange={(event) => setNodeForm((current) => ({ ...current, code: 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" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Label</span>
<input value={nodeForm.label} onChange={(event) => setNodeForm((current) => ({ ...current, label: 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" />
</label>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea value={nodeForm.description} onChange={(event) => setNodeForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] 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-xs font-semibold uppercase tracking-[0.16em] text-muted">Sort</span>
<input type="number" min={0} step={10} value={nodeForm.sortOrder} onChange={(event) => setNodeForm((current) => ({ ...current, sortOrder: 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>
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" disabled={!nodeForm.familyId}>Create branch</button>
</form>
</section>
) : null}
</div>
</div>
</section>
);
}

View File

@@ -32,25 +32,25 @@ export function WarehouseDetailPage() {
}, [token, warehouseId]); }, [token, warehouseId]);
if (!warehouse) { if (!warehouse) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>; return <div className="surface-panel text-sm text-muted">{status}</div>;
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Detail</p> <p className="section-kicker">WAREHOUSE DETAIL</p>
<h3 className="mt-2 text-2xl font-bold text-text">{warehouse.code}</h3> <h3 className="module-title">{warehouse.code}</h3>
<p className="mt-1 text-sm text-text">{warehouse.name}</p> <p className="text-sm text-text">{warehouse.name}</p>
<p className="mt-3 text-sm text-muted">Last updated {new Date(warehouse.updatedAt).toLocaleString()}.</p> <p className="mt-2 text-xs text-muted">Updated {new Date(warehouse.updatedAt).toLocaleString()}</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/inventory/warehouses" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <Link to="/inventory/warehouses" 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 warehouses Back to warehouses
</Link> </Link>
{canManage ? ( {canManage ? (
<Link to={`/inventory/warehouses/${warehouse.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"> <Link to={`/inventory/warehouses/${warehouse.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
Edit warehouse Edit warehouse
</Link> </Link>
) : null} ) : null}
@@ -58,27 +58,26 @@ export function WarehouseDetailPage() {
</div> </div>
</div> </div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p> <p className="section-kicker">NOTES</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{warehouse.notes || "No warehouse notes recorded."}</p> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{warehouse.notes || "No warehouse notes recorded."}</p>
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted"> <div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
Created {new Date(warehouse.createdAt).toLocaleDateString()} Created {new Date(warehouse.createdAt).toLocaleDateString()}
</div> </div>
</article> </article>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p> <p className="section-kicker">LOCATIONS</p>
<h4 className="mt-2 text-lg font-bold text-text">Stock locations</h4>
{warehouse.locations.length === 0 ? ( {warehouse.locations.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No stock locations have been defined for this warehouse yet. No stock locations have been defined for this warehouse yet.
</div> </div>
) : ( ) : (
<div className="mt-6 grid gap-3 xl:grid-cols-2"> <div className="mt-3 grid gap-2 xl:grid-cols-2">
{warehouse.locations.map((location: WarehouseLocationDto) => ( {warehouse.locations.map((location: WarehouseLocationDto) => (
<article key={location.id} className="rounded-3xl border border-line/70 bg-page/60 px-2 py-2"> <article key={location.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="text-sm font-semibold text-text">{location.code}</div> <div className="text-sm font-semibold text-text">{location.code}</div>
<div className="mt-1 text-sm text-text">{location.name}</div> <div className="mt-1 text-sm text-text">{location.name}</div>
<div className="mt-2 text-xs leading-6 text-muted">{location.notes || "No notes."}</div> <div className="mt-2 text-xs text-muted">{location.notes || "No notes."}</div>
</article> </article>
))} ))}
</div> </div>
@@ -88,3 +87,4 @@ export function WarehouseDetailPage() {
</section> </section>
); );
} }

View File

@@ -2,6 +2,7 @@ import type { WarehouseInput, WarehouseLocationInput } from "@mrp/shared/dist/in
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config"; import { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config";
@@ -13,6 +14,7 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
const [form, setForm] = useState<WarehouseInput>(emptyWarehouseInput); const [form, setForm] = useState<WarehouseInput>(emptyWarehouseInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new warehouse." : "Loading warehouse..."); const [status, setStatus] = useState(mode === "create" ? "Create a new warehouse." : "Loading warehouse...");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [pendingLocationRemovalIndex, setPendingLocationRemovalIndex] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
if (mode !== "edit" || !token || !warehouseId) { if (mode !== "edit" || !token || !warehouseId) {
@@ -67,6 +69,8 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
})); }));
} }
const pendingLocationRemoval = pendingLocationRemovalIndex != null ? form.locations[pendingLocationRemovalIndex] : null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -88,12 +92,12 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
} }
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Editor</p> <p className="section-kicker">WAREHOUSE EDITOR</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Warehouse" : "Edit Warehouse"}</h3> <h3 className="module-title">{mode === "create" ? "NEW WAREHOUSE" : "EDIT WAREHOUSE"}</h3>
</div> </div>
<Link <Link
to={mode === "create" ? "/inventory/warehouses" : `/inventory/warehouses/${warehouseId}`} to={mode === "create" ? "/inventory/warehouses" : `/inventory/warehouses/${warehouseId}`}
@@ -103,40 +107,39 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
</Link> </Link>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel space-y-3">
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse code</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse code</span>
<input value={form.code} onChange={(event) => updateField("code", 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" /> <input value={form.code} onChange={(event) => updateField("code", 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" />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse name</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse name</span>
<input value={form.name} onChange={(event) => updateField("name", 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" /> <input value={form.name} onChange={(event) => updateField("name", 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" />
</label> </label>
</div> </div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p> <p className="section-kicker">LOCATIONS</p>
<h4 className="mt-2 text-lg font-bold text-text">Internal stock locations</h4>
</div> </div>
<button type="button" onClick={addLocation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <button type="button" onClick={addLocation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add location Add location
</button> </button>
</div> </div>
{form.locations.length === 0 ? ( {form.locations.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No locations added yet. No locations added yet.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-4"> <div className="mt-3 space-y-3">
{form.locations.map((location: WarehouseLocationInput, index: number) => ( {form.locations.map((location: WarehouseLocationInput, index: number) => (
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={index} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="grid gap-3 xl:grid-cols-[0.7fr_1fr_auto]"> <div className="grid gap-3 xl:grid-cols-[0.7fr_1fr_auto]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
@@ -147,12 +150,12 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
<input value={location.name} onChange={(event) => updateLocation(index, { ...location, name: 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" /> <input value={location.name} onChange={(event) => updateLocation(index, { ...location, name: 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>
<div className="flex items-end"> <div className="flex items-end">
<button type="button" onClick={() => removeLocation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300"> <button type="button" onClick={() => setPendingLocationRemovalIndex(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 Remove
</button> </button>
</div> </div>
</div> </div>
<label className="mt-4 block"> <label className="mt-3 block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input value={location.notes} onChange={(event) => updateLocation(index, { ...location, notes: 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" /> <input value={location.notes} onChange={(event) => updateLocation(index, { ...location, notes: 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>
@@ -160,13 +163,29 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
))} ))}
</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"> <div className="mt-3 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> <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"> <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 warehouse" : "Save changes"} {isSaving ? "Saving..." : mode === "create" ? "Create warehouse" : "Save changes"}
</button> </button>
</div> </div>
</section> </section>
<ConfirmActionDialog
open={pendingLocationRemoval != null}
title="Remove warehouse location"
description={pendingLocationRemoval ? `Remove location ${pendingLocationRemoval.code || pendingLocationRemoval.name || "from this warehouse draft"}.` : "Remove this location."}
impact="The location will be removed from the warehouse edit form immediately."
recovery="Add the location back before saving if it should remain part of this warehouse."
confirmLabel="Remove location"
onClose={() => setPendingLocationRemovalIndex(null)}
onConfirm={() => {
if (pendingLocationRemovalIndex != null) {
removeLocation(pendingLocationRemovalIndex);
}
setPendingLocationRemovalIndex(null);
}}
/>
</form> </form>
); );
} }

View File

@@ -31,12 +31,11 @@ export function WarehousesPage() {
}, [token]); }, [token]);
return ( return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p> <p className="section-kicker">INVENTORY</p>
<h3 className="mt-2 text-lg font-bold text-text">Warehouses</h3> <h3 className="module-title">WAREHOUSES</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Physical warehouse records and their internal stock locations.</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link to="/inventory/warehouses/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white"> <Link to="/inventory/warehouses/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
@@ -44,13 +43,13 @@ export function WarehousesPage() {
</Link> </Link>
) : null} ) : null}
</div> </div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-3 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{warehouses.length === 0 ? ( {warehouses.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No warehouses have been added yet. No warehouses have been added yet.
</div> </div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-3 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
@@ -80,3 +79,4 @@ export function WarehousesPage() {
</section> </section>
); );
} }

View File

@@ -34,6 +34,7 @@ export const emptyInventoryOperationInput: InventoryItemOperationInput = {
export const emptyInventoryItemInput: InventoryItemInput = { export const emptyInventoryItemInput: InventoryItemInput = {
sku: "", sku: "",
skuBuilder: null,
name: "", name: "",
description: "", description: "",
type: "PURCHASED", type: "PURCHASED",
@@ -106,6 +107,7 @@ export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
}; };
export const inventoryFileOwnerType = "inventory-item"; export const inventoryFileOwnerType = "inventory-item";
export const inventoryThumbnailOwnerType = "inventory-item-thumbnail";
export const inventoryTypePalette: Record<InventoryItemType, string> = { export const inventoryTypePalette: Record<InventoryItemType, string> = {
PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300", PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",

View File

@@ -0,0 +1,386 @@
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
type LandingVariant = "light" | "dark";
const heroMetrics = [
{ label: "On-time release rate", value: "98.2%", detail: "across 146 active jobs this week" },
{ label: "Planner response time", value: "14 min", detail: "from demand signal to released draft supply" },
{ label: "Inventory exposure", value: "$1.4M", detail: "visible by warehouse, location, and reservation" },
];
const moduleCards = [
{
eyebrow: "Commercial Control",
title: "Quotes, orders, revisions, and approvals stay connected.",
copy: "Customer commitments flow directly into execution without spreadsheet handoffs or revision blind spots.",
bullets: ["Revision comparisons", "Approval checkpoints", "Project linkage"],
},
{
eyebrow: "Production Visibility",
title: "Planning, projects, and work orders share the same reality.",
copy: "Material readiness, shortages, pegged supply, and station execution all stay visible from one operational surface.",
bullets: ["Live readiness rollups", "Reservation automation", "Capacity-ready planning"],
},
{
eyebrow: "Inventory Discipline",
title: "SKU structure, stock control, and purchasing are built for actual manufacturing.",
copy: "Family-based SKU generation, BOM-aware demand, transfer visibility, and vendor-backed replenishment keep the floor aligned.",
bullets: ["Master SKU builder", "BOM explosion", "Preferred-vendor sourcing"],
},
];
const timeline = [
{ time: "06:40", title: "Demand spike detected", detail: "Three approved sales orders added 28 assemblies to near-term demand." },
{ time: "07:05", title: "Planner drafts supply", detail: "System nets stock, open POs, and open work orders before recommending build and buy actions." },
{ time: "08:10", title: "Reservations applied", detail: "Available stock updates immediately across project, manufacturing, and purchasing views." },
{ time: "09:25", title: "Shipment package released", detail: "Packing slip PDF and label artifacts are ready from the same order thread." },
];
const proofCards = [
{ label: "Modules live", value: "10", note: "CRM, inventory, sales, purchasing, shipping, projects, manufacturing, planning, admin, branding" },
{ label: "Data domains unified", value: "1", note: "Single operational model instead of disconnected tools" },
{ label: "Container footprint", value: "1", note: "Frontend, backend, SQLite, uploads, Puppeteer PDF pipeline" },
];
const spotlightBoard = [
{ title: "Revenue committed", value: "$842K", change: "+18% vs last month", tone: "brand" },
{ title: "Shortage lines", value: "12", change: "-31% after planner actions", tone: "accent" },
{ title: "Work orders ready", value: "27", change: "6 waiting on materials", tone: "neutral" },
{ title: "Shipments due today", value: "9", change: "4 already packed", tone: "brand" },
];
function getThemeVars(variant: LandingVariant): CSSProperties {
const vars = variant === "dark"
? {
colorScheme: "dark",
"--color-brand": "88 150 255",
"--color-accent": "44 214 199",
"--color-surface-brand": "18 30 52",
"--color-surface": "10 20 38",
"--color-page": "3 10 24",
"--color-text": "236 241 255",
"--color-muted": "150 168 194",
"--color-line": "68 87 120",
}
: {
colorScheme: "light",
"--color-brand": "29 78 216",
"--color-accent": "13 148 136",
"--color-surface-brand": "244 247 251",
"--color-surface": "244 247 251",
"--color-page": "239 244 255",
"--color-text": "15 23 42",
"--color-muted": "92 111 139",
"--color-line": "196 210 233",
};
return vars as CSSProperties;
}
function panelClass(isDark: boolean) {
return isDark
? "border-line/70 bg-surface/90 shadow-[0_30px_80px_rgba(2,6,23,0.45)]"
: "border-line/70 bg-surface/90 shadow-[0_30px_80px_rgba(15,23,42,0.12)]";
}
function softPanelClass(isDark: boolean) {
return isDark
? "border-line/70 bg-page/80 shadow-[0_20px_60px_rgba(2,6,23,0.34)]"
: "border-line/70 bg-page/75 shadow-[0_20px_50px_rgba(15,23,42,0.1)]";
}
function chipClass(isDark: boolean) {
return isDark ? "border-line/70 bg-page/80" : "border-line/70 bg-surface/90";
}
function LandingExperience({ variant }: { variant: LandingVariant }) {
const isDark = variant === "dark";
const themeVars = getThemeVars(variant);
const variantLabel = isDark ? "Dark landing" : "Light landing";
const altRoute = isDark ? "/landing" : "/darklanding";
const altLabel = isDark ? "View light version" : "View dark version";
return (
<div id="top" style={themeVars} className="min-h-screen bg-page text-text">
<div className="relative overflow-hidden">
<div
className={`absolute inset-0 ${
isDark
? "bg-[radial-gradient(circle_at_12%_10%,rgba(88,150,255,0.28),transparent_26%),radial-gradient(circle_at_82%_18%,rgba(44,214,199,0.2),transparent_24%),linear-gradient(180deg,rgba(255,255,255,0.04),transparent_42%)]"
: "bg-[radial-gradient(circle_at_12%_10%,rgba(59,130,246,0.24),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(20,184,166,0.18),transparent_24%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.08)_42%,transparent_60%)]"
}`}
/>
<div className={`absolute left-[-8%] top-20 h-80 w-80 rounded-full blur-3xl ${isDark ? "bg-sky-400/20" : "bg-blue-300/40"}`} />
<div className={`absolute right-[-10%] top-32 h-[28rem] w-[28rem] rounded-full blur-3xl ${isDark ? "bg-teal-300/16" : "bg-cyan-200/50"}`} />
<div className={`absolute left-[22%] top-[32rem] h-64 w-64 rounded-full blur-3xl ${isDark ? "bg-indigo-400/10" : "bg-violet-200/35"}`} />
<div className="relative mx-auto flex w-full max-w-7xl flex-col gap-12 px-6 pb-20 pt-8 lg:px-8">
<header className={`flex flex-col gap-4 rounded-[28px] border px-5 py-4 xl:flex-row xl:items-center xl:justify-between ${panelClass(isDark)}`}>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,rgb(var(--color-brand)),rgb(var(--color-accent)))] text-lg font-black tracking-[0.18em] text-white">
CX
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.34em] text-muted">CODEXIUM</div>
<div className="text-sm text-muted">Manufacturing resource planning without the ERP drag.</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className={`rounded-2xl border px-4 py-2 text-sm font-semibold text-text ${chipClass(isDark)}`}>
{variantLabel}
</span>
<Link to={altRoute} className={`rounded-2xl border px-4 py-2 text-sm font-semibold text-text transition hover:bg-page/70 ${chipClass(isDark)}`}>
{altLabel}
</Link>
<Link to="/login" className={`rounded-2xl border px-4 py-2 text-sm font-semibold text-text transition hover:bg-page/70 ${chipClass(isDark)}`}>
Open app
</Link>
<a
href="#contact"
className="rounded-2xl bg-[linear-gradient(135deg,rgb(var(--color-brand)),rgb(var(--color-accent)))] px-4 py-2 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(24,90,219,0.28)]"
>
Book a walkthrough
</a>
</div>
</header>
<section className="grid gap-10 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<div>
<div className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-muted ${chipClass(isDark)}`}>
Built for discrete manufacturing teams
</div>
<h1 className="mt-6 max-w-4xl font-['Space_Grotesk','Manrope',sans-serif] text-5xl font-black leading-[0.95] tracking-[-0.05em] text-text sm:text-6xl xl:text-7xl">
A sharper MRP for operators who need flow, not ERP theater.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted">
CODEXIUM connects commercial demand, inventory truth, project execution, purchasing, shipping, and manufacturing control in one system designed to move work across the floor.
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{heroMetrics.map((metric) => (
<article key={metric.label} className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">{metric.label}</div>
<div className="mt-3 text-3xl font-black tracking-[-0.04em] text-text">{metric.value}</div>
<div className="mt-2 text-sm text-muted">{metric.detail}</div>
</article>
))}
</div>
</div>
<div className="relative">
<div className={`absolute -left-8 top-10 hidden h-20 w-20 rounded-[28px] border lg:block ${isDark ? "border-white/20 bg-white/10" : "border-white/40 bg-white/20"}`} />
<div className={`rounded-[32px] border p-5 ${isDark ? "bg-[linear-gradient(160deg,rgba(15,23,42,0.82),rgba(15,23,42,0.48))] shadow-[0_35px_90px_rgba(2,6,23,0.55)]" : "bg-[linear-gradient(160deg,rgba(255,255,255,0.68),rgba(255,255,255,0.24))] shadow-[0_35px_90px_rgba(15,23,42,0.18)]"} ${isDark ? "border-line/70" : "border-line/70"}`}>
<div className={`rounded-[28px] border p-5 ${softPanelClass(isDark)}`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Generated executive board</div>
<div className="mt-2 text-2xl font-black tracking-[-0.04em] text-text">This week in operations</div>
</div>
<div className="rounded-full border border-emerald-400/35 bg-emerald-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300">
Live-ready demo data
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{spotlightBoard.map((card) => (
<article
key={card.title}
className={`rounded-[24px] border p-4 ${
card.tone === "brand"
? isDark
? "border-[rgb(var(--color-brand)/0.25)] bg-[rgb(var(--color-brand)/0.08)]"
: "border-[rgb(var(--color-brand)/0.25)] bg-[rgb(var(--color-brand)/0.08)]"
: card.tone === "accent"
? isDark
? "border-[rgb(var(--color-accent)/0.25)] bg-[rgb(var(--color-accent)/0.08)]"
: "border-[rgb(var(--color-accent)/0.25)] bg-[rgb(var(--color-accent)/0.08)]"
: softPanelClass(isDark)
}`}
>
<div className="text-sm font-semibold text-muted">{card.title}</div>
<div className="mt-3 text-4xl font-black tracking-[-0.05em] text-text">{card.value}</div>
<div className="mt-2 text-sm text-muted">{card.change}</div>
</article>
))}
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-[1.3fr_0.7fr]">
<div className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-text">Project and manufacturing pulse</div>
<div className="text-xs uppercase tracking-[0.18em] text-muted">Generated snapshot</div>
</div>
<div className="mt-4 space-y-3">
{[
{ label: "Falcon enclosure program", progress: 86, status: "Ready for final assembly" },
{ label: "Orion service kit launch", progress: 61, status: "Waiting on one purchased line" },
{ label: "Atlas retrofit release", progress: 43, status: "Routing review in progress" },
].map((row) => (
<div key={row.label} className={`rounded-[20px] border p-3 ${isDark ? "border-line/70 bg-page/75" : "border-line/60 bg-page/75"}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-semibold text-text">{row.label}</div>
<div className="text-xs uppercase tracking-[0.18em] text-muted">{row.progress}%</div>
</div>
<div className={`mt-3 h-2 overflow-hidden rounded-full ${isDark ? "bg-line/70" : "bg-line/70"}`}>
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgb(var(--color-brand)),rgb(var(--color-accent)))]"
style={{ width: `${row.progress}%` }}
/>
</div>
<div className="mt-2 text-sm text-muted">{row.status}</div>
</div>
))}
</div>
</div>
<div className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
<div className="text-sm font-semibold text-text">Supply signals</div>
<div className="mt-4 space-y-3">
{[
{ sku: "AXL-4472", action: "Build", qty: "18 EA" },
{ sku: "KIT-2208", action: "Buy", qty: "240 EA" },
{ sku: "CAB-9031", action: "Transfer", qty: "64 EA" },
].map((signal) => (
<div key={signal.sku} className={`rounded-[18px] border px-3 py-3 ${isDark ? "border-line/70 bg-page/75" : "border-line/60 bg-page/75"}`}>
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{signal.sku}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-muted">{signal.action} recommendation</div>
</div>
<div className="text-sm font-semibold text-text">{signal.qty}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section className={`grid gap-4 rounded-[32px] border p-6 lg:grid-cols-3 ${panelClass(isDark)}`}>
{proofCards.map((card) => (
<article key={card.label} className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">{card.label}</div>
<div className="mt-3 text-5xl font-black tracking-[-0.06em] text-text">{card.value}</div>
<div className="mt-2 text-sm text-muted">{card.note}</div>
</article>
))}
</section>
</div>
</div>
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-6 pb-24 lg:px-8">
<section className="grid gap-6 lg:grid-cols-3">
{moduleCards.map((card) => (
<article key={card.title} className={`rounded-[28px] border p-6 ${panelClass(isDark)}`}>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">{card.eyebrow}</div>
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-2xl font-bold tracking-[-0.04em] text-text">{card.title}</h2>
<p className="mt-4 text-sm leading-7 text-muted">{card.copy}</p>
<div className="mt-6 flex flex-wrap gap-2">
{card.bullets.map((bullet) => (
<span key={bullet} className={`rounded-full border px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-text ${chipClass(isDark)}`}>
{bullet}
</span>
))}
</div>
</article>
))}
</section>
<section className={`grid gap-8 rounded-[32px] border p-6 lg:grid-cols-[0.9fr_1.1fr] ${panelClass(isDark)}`}>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">How it moves</div>
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-4xl font-black tracking-[-0.05em] text-text">
One demand signal. One operating picture.
</h2>
<p className="mt-4 max-w-xl text-base leading-8 text-muted">
Most systems split quoting, planning, purchasing, inventory, shipping, and production into separate stories. CODEXIUM treats them as one chain, so each step inherits context instead of creating reconciliation work.
</p>
</div>
<div className="space-y-4">
{timeline.map((step) => (
<article key={step.time} className={`grid gap-4 rounded-[24px] border p-4 md:grid-cols-[92px_1fr] md:items-start ${softPanelClass(isDark)}`}>
<div className={`rounded-[18px] px-4 py-3 text-center ${isDark ? "bg-surface/80" : "bg-surface/90"}`}>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Time</div>
<div className="mt-1 text-2xl font-black tracking-[-0.04em] text-text">{step.time}</div>
</div>
<div>
<div className="text-lg font-bold text-text">{step.title}</div>
<div className="mt-2 text-sm leading-7 text-muted">{step.detail}</div>
</div>
</article>
))}
</div>
</section>
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<article className={`rounded-[32px] border p-6 ${panelClass(isDark)}`}>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Who this is for</div>
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-4xl font-black tracking-[-0.05em] text-text">
Teams graduating from spreadsheets, generic ERPs, or disconnected point tools.
</h2>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{[
"Contract manufacturers that need real-time supply readiness before promising dates.",
"OEM operations teams that need projects, BOMs, purchasing, and work orders linked end to end.",
"Growing production shops that want branding, PDFs, approvals, and auditability without enterprise bloat.",
"Leaders who want a system operators will actually open all day, not just during month-end cleanup.",
].map((statement) => (
<div key={statement} className={`rounded-[22px] border p-4 text-sm leading-7 text-text ${softPanelClass(isDark)}`}>
{statement}
</div>
))}
</div>
</article>
<article id="contact" className={`rounded-[32px] border p-6 ${panelClass(isDark)}`}>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Next step</div>
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-3xl font-black tracking-[-0.05em] text-text">
Pitch the platform with a page that already looks production-grade.
</h2>
<p className="mt-4 text-sm leading-7 text-muted">
Use this route as a commercial front door, demo backdrop, or customer-facing preview while the core app remains focused on execution.
</p>
<div className={`mt-6 space-y-3 rounded-[24px] border p-4 text-sm text-muted ${softPanelClass(isDark)}`}>
<div className="flex items-center justify-between gap-3">
<span>Public route</span>
<span className="font-semibold text-text">{isDark ? "/darklanding" : "/landing"}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Core positioning</span>
<span className="font-semibold text-text">Modern manufacturing MRP</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Demo style</span>
<span className="font-semibold text-text">Generated commercial data</span>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
to="/login"
className="rounded-2xl bg-[linear-gradient(135deg,rgb(var(--color-brand)),rgb(var(--color-accent)))] px-4 py-3 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(24,90,219,0.28)]"
>
Enter the app
</Link>
<a href="#top" className={`rounded-2xl border px-4 py-3 text-sm font-semibold text-text transition hover:bg-page/70 ${chipClass(isDark)}`}>
Back to top
</a>
</div>
</article>
</section>
</main>
</div>
);
}
export function LandingPage() {
return <LandingExperience variant="light" />;
}
export function DarkLandingPage() {
return <LandingExperience variant="dark" />;
}

View File

@@ -10,6 +10,9 @@ const emptyStationInput: ManufacturingStationInput = {
name: "", name: "",
description: "", description: "",
queueDays: 0, queueDays: 0,
dailyCapacityMinutes: 480,
parallelCapacity: 1,
workingDays: [1, 2, 3, 4, 5],
isActive: true, isActive: true,
}; };
@@ -17,6 +20,7 @@ export function ManufacturingPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const [stations, setStations] = useState<ManufacturingStationDto[]>([]); const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [form, setForm] = useState<ManufacturingStationInput>(emptyStationInput); const [form, setForm] = useState<ManufacturingStationInput>(emptyStationInput);
const [editingStationId, setEditingStationId] = useState<string | null>(null);
const [status, setStatus] = useState("Define manufacturing stations once so routings and work orders can schedule automatically."); const [status, setStatus] = useState("Define manufacturing stations once so routings and work orders can schedule automatically.");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false; const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
@@ -29,6 +33,27 @@ export function ManufacturingPage() {
api.getManufacturingStations(token).then(setStations).catch(() => setStations([])); api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
}, [token]); }, [token]);
function resetForm(nextStatus = "Define manufacturing stations once so routings and work orders can schedule automatically.") {
setForm(emptyStationInput);
setEditingStationId(null);
setStatus(nextStatus);
}
function startEditing(station: ManufacturingStationDto) {
setEditingStationId(station.id);
setForm({
code: station.code,
name: station.name,
description: station.description,
queueDays: station.queueDays,
dailyCapacityMinutes: station.dailyCapacityMinutes,
parallelCapacity: station.parallelCapacity,
workingDays: station.workingDays,
isActive: station.isActive,
});
setStatus(`Editing station ${station.code}.`);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -36,12 +61,15 @@ export function ManufacturingPage() {
} }
setIsSaving(true); setIsSaving(true);
setStatus("Saving station..."); setStatus(editingStationId ? "Updating station..." : "Saving station...");
try { try {
const station = await api.createManufacturingStation(token, form); const station = editingStationId
setStations((current) => [...current, station].sort((left, right) => left.code.localeCompare(right.code))); ? await api.updateManufacturingStation(token, editingStationId, form)
setForm(emptyStationInput); : await api.createManufacturingStation(token, form);
setStatus("Station saved."); setStations((current) =>
(editingStationId ? current.map((entry) => (entry.id === station.id ? station : entry)) : [...current, station]).sort((left, right) => left.code.localeCompare(right.code))
);
resetForm(editingStationId ? "Station updated." : "Station saved.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save station."; const message = error instanceof ApiError ? error.message : "Unable to save station.";
setStatus(message); setStatus(message);
@@ -51,27 +79,33 @@ export function ManufacturingPage() {
} }
return ( return (
<div className="space-y-4"> <div className="page-stack">
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]"> <section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Stations</p> <p className="section-kicker">MANUFACTURING STATIONS</p>
<h3 className="mt-2 text-xl font-bold text-text">Scheduling anchors</h3> <h3 className="module-title">SCHEDULING ANCHORS</h3>
<p className="mt-2 text-sm text-muted">Stations define where operation time belongs. Buildable items reference them in their routing template, and work orders inherit those steps automatically into planning.</p>
{stations.length === 0 ? ( {stations.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No stations defined yet. No stations defined yet.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{stations.map((station) => ( {stations.map((station) => (
<article key={station.id} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <article key={station.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">{station.code} - {station.name}</div> <div className="font-semibold text-text">{station.code} - {station.name}</div>
<div className="mt-1 text-xs text-muted">{station.description || "No description"}</div> <div className="mt-1 text-xs text-muted">{station.description || "No description"}</div>
{canManage ? (
<button type="button" onClick={() => startEditing(station)} className="mt-3 rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text">
Edit station
</button>
) : null}
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>{station.queueDays} expected wait day(s)</div> <div>{station.queueDays} expected wait day(s)</div>
<div>{station.dailyCapacityMinutes} min/day x {station.parallelCapacity}</div>
<div>Days {station.workingDays.join(",")}</div>
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div> <div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
</div> </div>
</div> </div>
@@ -81,24 +115,64 @@ export function ManufacturingPage() {
)} )}
</article> </article>
{canManage ? ( {canManage ? (
<form onSubmit={handleSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <form onSubmit={handleSubmit} className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">New Station</p> <p className="section-kicker">{editingStationId ? "EDIT STATION" : "NEW STATION"}</p>
<div className="mt-4 grid gap-3"> <div className="mt-3 grid gap-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Code</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
<input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: 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" /> <input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: 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" />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Name</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: 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" /> <input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: 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" />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Expected Wait (Days)</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Expected wait (days)</span>
<input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 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>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Capacity minutes / day</span>
<input type="number" min={60} step={30} value={form.dailyCapacityMinutes} onChange={(event) => setForm((current) => ({ ...current, dailyCapacityMinutes: Number.parseInt(event.target.value, 10) || 480 }))} 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-xs font-semibold uppercase tracking-[0.16em] text-muted">Parallel capacity</span>
<input type="number" min={1} step={1} value={form.parallelCapacity} onChange={(event) => setForm((current) => ({ ...current, parallelCapacity: Number.parseInt(event.target.value, 10) || 1 }))} 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"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Working days</span>
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: 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" /> <div className="flex flex-wrap gap-2">
{[
{ value: 1, label: "Mon" },
{ value: 2, label: "Tue" },
{ value: 3, label: "Wed" },
{ value: 4, label: "Thu" },
{ value: 5, label: "Fri" },
{ value: 6, label: "Sat" },
{ value: 0, label: "Sun" },
].map((day) => (
<label key={day.value} className="flex items-center gap-2 rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text">
<input
type="checkbox"
checked={form.workingDays.includes(day.value)}
onChange={(event) =>
setForm((current) => ({
...current,
workingDays: event.target.checked
? [...current.workingDays, day.value].sort((left, right) => left - right)
: current.workingDays.filter((value) => value !== day.value),
}))
}
/>
<span>{day.label}</span>
</label>
))}
</div>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2"> <label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} /> <input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} />
@@ -106,9 +180,16 @@ export function ManufacturingPage() {
</label> </label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2"> <div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{status}</span> <span className="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"> <div className="flex flex-wrap gap-2">
{isSaving ? "Saving..." : "Create station"} <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">
</button> {isSaving ? (editingStationId ? "Updating..." : "Saving...") : editingStationId ? "Update station" : "Create station"}
</button>
{editingStationId ? (
<button type="button" onClick={() => resetForm("Edit cancelled.")} disabled={isSaving} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
Cancel
</button>
) : null}
</div>
</div> </div>
</div> </div>
</form> </form>
@@ -118,3 +199,4 @@ export function ManufacturingPage() {
</div> </div>
); );
} }

View File

@@ -1,5 +1,16 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared"; import type {
ManufacturingUserOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderMaterialIssueInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderStatus,
} 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, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -7,6 +18,7 @@ import { Link, useParams } from "react-router-dom";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel"; import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config"; import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge"; import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
@@ -15,12 +27,37 @@ export function WorkOrderDetailPage() {
const { workOrderId } = useParams(); const { workOrderId } = useParams();
const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null); const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]); const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput); const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput); const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
const [holdReasonDraft, setHoldReasonDraft] = useState("");
const [status, setStatus] = useState("Loading work order..."); const [status, setStatus] = useState("Loading work order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isPostingIssue, setIsPostingIssue] = useState(false); const [isPostingIssue, setIsPostingIssue] = useState(false);
const [isPostingCompletion, setIsPostingCompletion] = useState(false); const [isPostingCompletion, setIsPostingCompletion] = useState(false);
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
const [operationLaborForm, setOperationLaborForm] = useState<Record<string, WorkOrderOperationLaborEntryInput>>({});
const [operationAssignmentForm, setOperationAssignmentForm] = useState<Record<string, WorkOrderOperationAssignmentInput>>({});
const [operationTimerForm, setOperationTimerForm] = useState<Record<string, WorkOrderOperationTimerInput>>({});
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
const [executingOperationId, setExecutingOperationId] = useState<string | null>(null);
const [postingLaborOperationId, setPostingLaborOperationId] = useState<string | null>(null);
const [assigningOperationId, setAssigningOperationId] = useState<string | null>(null);
const [timerOperationId, setTimerOperationId] = useState<string | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "issue" | "completion";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus?: WorkOrderStatus;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false; const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
@@ -41,6 +78,26 @@ export function WorkOrderDetailPage() {
...emptyCompletionInput, ...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1), quantity: Math.max(nextWorkOrder.dueQuantity, 1),
}); });
setOperationScheduleForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
)
);
setOperationLaborForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { minutes: Math.max(Math.round(operation.plannedMinutes / 4), 15), notes: "" }])
)
);
setOperationAssignmentForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { assignedOperatorId: operation.assignedOperatorId }])
)
);
setOperationTimerForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { action: operation.activeTimerStartedAt ? "STOP" : "START", notes: "" }])
)
);
setStatus("Work order loaded."); setStatus("Work order loaded.");
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -49,6 +106,7 @@ export function WorkOrderDetailPage() {
}); });
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([])); api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
api.getManufacturingUserOptions(token).then(setOperatorOptions).catch(() => setOperatorOptions([]));
}, [token, workOrderId]); }, [token, workOrderId]);
const filteredLocationOptions = useMemo( const filteredLocationOptions = useMemo(
@@ -56,7 +114,7 @@ export function WorkOrderDetailPage() {
[issueForm.warehouseId, locationOptions] [issueForm.warehouseId, locationOptions]
); );
async function handleStatusChange(nextStatus: WorkOrderStatus) { async function applyStatusChange(nextStatus: WorkOrderStatus) {
if (!token || !workOrder) { if (!token || !workOrder) {
return; return;
} }
@@ -64,9 +122,13 @@ export function WorkOrderDetailPage() {
setIsUpdatingStatus(true); setIsUpdatingStatus(true);
setStatus("Updating work-order status..."); setStatus("Updating work-order status...");
try { try {
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus); const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, {
status: nextStatus,
reason: nextStatus === "ON_HOLD" ? holdReasonDraft : null,
});
setWorkOrder(nextWorkOrder); setWorkOrder(nextWorkOrder);
setStatus("Work-order status updated."); setHoldReasonDraft("");
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update work-order status."; const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
setStatus(message); setStatus(message);
@@ -75,8 +137,7 @@ export function WorkOrderDetailPage() {
} }
} }
async function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) { async function submitIssue() {
event.preventDefault();
if (!token || !workOrder) { if (!token || !workOrder) {
return; return;
} }
@@ -91,7 +152,7 @@ export function WorkOrderDetailPage() {
warehouseId: nextWorkOrder.warehouseId, warehouseId: nextWorkOrder.warehouseId,
locationId: nextWorkOrder.locationId, locationId: nextWorkOrder.locationId,
}); });
setStatus("Material issue posted."); setStatus("Material issue posted. This consumed inventory immediately; post a correcting stock movement if the issue quantity was wrong.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post material issue."; const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
setStatus(message); setStatus(message);
@@ -100,8 +161,7 @@ export function WorkOrderDetailPage() {
} }
} }
async function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) { async function submitCompletion() {
event.preventDefault();
if (!token || !workOrder) { if (!token || !workOrder) {
return; return;
} }
@@ -115,7 +175,7 @@ export function WorkOrderDetailPage() {
...emptyCompletionInput, ...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1), quantity: Math.max(nextWorkOrder.dueQuantity, 1),
}); });
setStatus("Completion posted."); setStatus("Completion posted. Finished-goods stock has been received; verify the remaining quantity and post a correcting transaction if this completion was overstated.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post completion."; const message = error instanceof ApiError ? error.message : "Unable to post completion.";
setStatus(message); setStatus(message);
@@ -124,19 +184,227 @@ export function WorkOrderDetailPage() {
} }
} }
async function submitOperationReschedule(operationId: string) {
if (!token || !workOrder) {
return;
}
const payload = operationScheduleForm[operationId];
if (!payload?.plannedStart) {
return;
}
setReschedulingOperationId(operationId);
setStatus("Rebuilding operation schedule...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationSchedule(token, workOrder.id, operationId, payload);
setWorkOrder(nextWorkOrder);
setOperationScheduleForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
)
);
setStatus("Operation schedule updated with station calendar constraints.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to reschedule operation.";
setStatus(message);
} finally {
setReschedulingOperationId(null);
}
}
async function submitOperationExecution(operationId: string, action: WorkOrderOperationExecutionInput["action"]) {
if (!token || !workOrder) {
return;
}
setExecutingOperationId(operationId);
setStatus("Updating operation execution...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationExecution(token, workOrder.id, operationId, {
action,
notes: `${action} from work-order detail`,
});
setWorkOrder(nextWorkOrder);
setOperationScheduleForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
)
);
setStatus("Operation execution updated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update operation execution.";
setStatus(message);
} finally {
setExecutingOperationId(null);
}
}
async function submitOperationLabor(operationId: string) {
if (!token || !workOrder) {
return;
}
const payload = operationLaborForm[operationId];
if (!payload?.minutes) {
return;
}
setPostingLaborOperationId(operationId);
setStatus("Posting labor entry...");
try {
const nextWorkOrder = await api.recordWorkOrderOperationLabor(token, workOrder.id, operationId, payload);
setWorkOrder(nextWorkOrder);
setOperationLaborForm((current) => ({
...current,
[operationId]: {
minutes: Math.max(Math.round((nextWorkOrder.operations.find((operation) => operation.id === operationId)?.plannedMinutes ?? 60) / 4), 15),
notes: "",
},
}));
setStatus("Labor entry posted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post operation labor.";
setStatus(message);
} finally {
setPostingLaborOperationId(null);
}
}
async function submitOperationAssignment(operationId: string) {
if (!token || !workOrder) {
return;
}
const payload = operationAssignmentForm[operationId];
if (!payload) {
return;
}
setAssigningOperationId(operationId);
setStatus("Updating operator assignment...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationAssignment(token, workOrder.id, operationId, payload);
setWorkOrder(nextWorkOrder);
setStatus("Operator assignment updated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update operator assignment.";
setStatus(message);
} finally {
setAssigningOperationId(null);
}
}
async function submitOperationTimer(operationId: string, action: WorkOrderOperationTimerInput["action"]) {
if (!token || !workOrder) {
return;
}
const payload = operationTimerForm[operationId] ?? { action, notes: "" };
setTimerOperationId(operationId);
setStatus(action === "START" ? "Starting timer..." : "Stopping timer...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationTimer(token, workOrder.id, operationId, {
action,
notes: payload.notes,
});
setWorkOrder(nextWorkOrder);
setOperationTimerForm((current) => ({
...current,
[operationId]: {
action: action === "START" ? "STOP" : "START",
notes: "",
},
}));
setStatus(action === "START" ? "Operation timer started." : "Operation timer stopped and labor posted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update operation timer.";
setStatus(message);
} finally {
setTimerOperationId(null);
}
}
function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!workOrder) {
return;
}
const option = workOrderStatusOptions.find((entry) => entry.value === nextStatus);
setPendingConfirmation({
kind: "status",
title: `Change status to ${option?.label ?? nextStatus}`,
description: `Update work order ${workOrder.workOrderNumber} from ${workOrder.status} to ${nextStatus}.`,
impact:
nextStatus === "CANCELLED"
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
: nextStatus === "ON_HOLD"
? "Putting a work order on hold pauses expected execution and should capture the exact blocker so planning and shop-floor review stay aligned."
: nextStatus === "COMPLETE"
? "Completing the work order signals execution closure and can change readiness views across the system."
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
recovery: "If this status was selected in error, set the work order back to the correct state immediately after review.",
confirmLabel: `Set ${option?.label ?? nextStatus}`,
confirmationLabel: nextStatus === "CANCELLED" ? "Type work-order number to confirm:" : undefined,
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
nextStatus,
});
setHoldReasonDraft(nextStatus === "ON_HOLD" ? workOrder.holdReason ?? "" : "");
}
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!workOrder) {
return;
}
const component = workOrder.materialRequirements.find((requirement) => requirement.componentItemId === issueForm.componentItemId);
setPendingConfirmation({
kind: "issue",
title: "Post material issue",
description: `Issue ${issueForm.quantity} units of ${component?.componentSku ?? "the selected component"} to work order ${workOrder.workOrderNumber}.`,
impact: "This consumes component inventory immediately and updates work-order material history.",
recovery: "If the wrong quantity was issued, post a correcting stock transaction and note the reason on the work order.",
confirmLabel: "Post issue",
confirmationLabel: "Type work-order number to confirm:",
confirmationValue: workOrder.workOrderNumber,
});
}
function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!workOrder) {
return;
}
setPendingConfirmation({
kind: "completion",
title: "Post production completion",
description: `Receive ${completionForm.quantity} finished units into ${workOrder.warehouseCode} / ${workOrder.locationCode}.`,
impact: "This increases finished-goods inventory immediately and advances the execution history for this work order.",
recovery: "If the completion quantity is wrong, post the correcting inventory movement and verify the work-order remaining quantity.",
confirmLabel: "Post completion",
confirmationLabel: completionForm.quantity >= workOrder.dueQuantity ? "Type work-order number to confirm:" : undefined,
confirmationValue: completionForm.quantity >= workOrder.dueQuantity ? workOrder.workOrderNumber : undefined,
});
}
if (!workOrder) { if (!workOrder) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Order</p> <p className="section-kicker">WORK ORDER</p>
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3> <h3 className="module-title">{workOrder.workOrderNumber}</h3>
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p> <p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
<div className="mt-3"><WorkOrderStatusBadge status={workOrder.status} /></div> <div className="mt-2.5"><WorkOrderStatusBadge status={workOrder.status} /></div>
{workOrder.status === "ON_HOLD" && workOrder.holdReason ? (
<div className="mt-2.5 max-w-2xl rounded-[16px] border border-amber-300/60 bg-amber-50 px-3 py-2.5 text-sm text-amber-900">
<div className="metric-kicker text-amber-900">Current Hold Reason</div>
<div className="mt-2 whitespace-pre-line">{workOrder.holdReason}</div>
</div>
) : null}
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/manufacturing/work-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 work orders</Link> <Link to="/manufacturing/work-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 work orders</Link>
@@ -148,11 +416,10 @@ export function WorkOrderDetailPage() {
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p> <p className="section-kicker">QUICK ACTIONS</p>
<p className="mt-2 text-sm text-muted">Release, hold, or close administrative status from the work-order record.</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{workOrderStatusOptions.map((option) => ( {workOrderStatusOptions.map((option) => (
@@ -164,19 +431,20 @@ export function WorkOrderDetailPage() {
</div> </div>
</section> </section>
) : null} ) : null}
<section className="grid gap-3 xl:grid-cols-6"> <section className="grid gap-2 xl:grid-cols-6">
<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">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</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">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Completed</p><div className="mt-1 text-base font-bold text-text">{workOrder.completedQuantity}</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">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Remaining</p><div className="mt-1 text-base font-bold text-text">{workOrder.dueQuantity}</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">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-1 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</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">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-1 text-base font-bold text-text">{workOrder.operations.length}</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">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-1 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</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">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-1 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Actual Hours</p><div className="mt-1 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Execution Context</p> <p className="section-kicker">EXECUTION CONTEXT</p>
<dl className="mt-5 grid gap-3"> <dl className="mt-3 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
@@ -184,25 +452,28 @@ export function WorkOrderDetailPage() {
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
</dl> </dl>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Instructions</p> <p className="section-kicker">WORK INSTRUCTIONS</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
</article> </article>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Operation Plan</p> <p className="section-kicker">OPERATION PLAN</p>
{workOrder.operations.length === 0 ? ( {workOrder.operations.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">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
) : ( ) : (
<div className="mt-5 overflow-hidden rounded-3xl border border-line/70"> <div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70"> <thead className="bg-page/70">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted"> <tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">Seq</th> <th className="px-3 py-3">Seq</th>
<th className="px-3 py-3">Station</th> <th className="px-3 py-3">Station</th>
<th className="px-3 py-3">Execution</th>
<th className="px-3 py-3">Capacity</th>
<th className="px-3 py-3">Start</th> <th className="px-3 py-3">Start</th>
<th className="px-3 py-3">End</th> <th className="px-3 py-3">End</th>
<th className="px-3 py-3">Minutes</th> <th className="px-3 py-3">Planned / Actual</th>
{canManage ? <th className="px-3 py-3">Execution Controls</th> : null}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-line/70"> <tbody className="divide-y divide-line/70">
@@ -213,9 +484,160 @@ export function WorkOrderDetailPage() {
<div className="font-semibold text-text">{operation.stationCode}</div> <div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div> <div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td> </td>
<td className="px-3 py-3 text-xs text-muted">
<div className="font-semibold text-text">{operation.status.replaceAll("_", " ")}</div>
<div className="mt-1">Start {operation.actualStart ? new Date(operation.actualStart).toLocaleString() : "Not started"}</div>
<div>End {operation.actualEnd ? new Date(operation.actualEnd).toLocaleString() : "Open"}</div>
<div>Operator {operation.assignedOperatorName ?? "Unassigned"}</div>
<div>{operation.activeTimerStartedAt ? `Timer running since ${new Date(operation.activeTimerStartedAt).toLocaleTimeString()}` : "Timer stopped"}</div>
<div>{operation.laborEntryCount} labor entr{operation.laborEntryCount === 1 ? "y" : "ies"}</div>
</td>
<td className="px-3 py-3 text-xs text-muted">
<div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div>
<div>{operation.stationWorkingDays.join(",")}</div>
</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td> <td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td> <td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{operation.plannedMinutes}</td> <td className="px-3 py-3 text-xs text-text">
<div>{operation.plannedMinutes} planned</div>
<div className="mt-1">{operation.actualMinutes} actual</div>
</td>
{canManage ? (
<td className="px-3 py-3">
<div className="min-w-[320px] space-y-2">
<div className="flex flex-wrap gap-2">
{operation.status === "PENDING" ? (
<button type="button" onClick={() => void submitOperationExecution(operation.id, "START")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Start</button>
) : null}
{(operation.status === "PENDING" || operation.status === "PAUSED") ? (
<button type="button" onClick={() => void submitOperationExecution(operation.id, "RESUME")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Resume</button>
) : null}
{operation.status === "IN_PROGRESS" ? (
<button type="button" onClick={() => void submitOperationExecution(operation.id, "PAUSE")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Pause</button>
) : null}
{operation.status !== "COMPLETE" ? (
<button type="button" onClick={() => void submitOperationExecution(operation.id, "COMPLETE")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Complete</button>
) : null}
</div>
<div className="flex items-center gap-2">
<select
value={operationAssignmentForm[operation.id]?.assignedOperatorId ?? ""}
onChange={(event) =>
setOperationAssignmentForm((current) => ({
...current,
[operation.id]: {
assignedOperatorId: event.target.value || null,
},
}))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
>
<option value="">Unassigned operator</option>
{operatorOptions.map((operator) => (
<option key={operator.id} value={operator.id}>
{operator.name} ({operator.email})
</option>
))}
</select>
<button
type="button"
onClick={() => void submitOperationAssignment(operation.id)}
disabled={assigningOperationId === operation.id}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{assigningOperationId === operation.id ? "Saving..." : "Assign"}
</button>
</div>
<input
type="datetime-local"
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
onChange={(event) =>
setOperationScheduleForm((current) => ({
...current,
[operation.id]: { plannedStart: new Date(event.target.value).toISOString() },
}))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
step={1}
value={operationLaborForm[operation.id]?.minutes ?? 15}
onChange={(event) =>
setOperationLaborForm((current) => ({
...current,
[operation.id]: {
...(current[operation.id] ?? { notes: "" }),
minutes: Number.parseInt(event.target.value, 10) || 1,
},
}))
}
className="w-24 rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
<input
type="text"
placeholder="Labor note"
value={operationLaborForm[operation.id]?.notes ?? ""}
onChange={(event) =>
setOperationLaborForm((current) => ({
...current,
[operation.id]: {
...(current[operation.id] ?? { minutes: 15 }),
notes: event.target.value,
},
}))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder={operation.activeTimerStartedAt ? "Stop timer note" : "Start timer note"}
value={operationTimerForm[operation.id]?.notes ?? ""}
onChange={(event) =>
setOperationTimerForm((current) => ({
...current,
[operation.id]: {
action: operation.activeTimerStartedAt ? "STOP" : "START",
notes: event.target.value,
},
}))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
<button
type="button"
onClick={() => void submitOperationTimer(operation.id, operation.activeTimerStartedAt ? "STOP" : "START")}
disabled={timerOperationId === operation.id || operation.status === "COMPLETE"}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{timerOperationId === operation.id ? "Saving..." : operation.activeTimerStartedAt ? "Stop timer" : "Start timer"}
</button>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void submitOperationReschedule(operation.id)}
disabled={reschedulingOperationId === operation.id}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{reschedulingOperationId === operation.id ? "Saving..." : "Apply plan"}
</button>
<button
type="button"
onClick={() => void submitOperationLabor(operation.id)}
disabled={postingLaborOperationId === operation.id || operation.status === "COMPLETE"}
className="rounded-2xl bg-brand px-2 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{postingLaborOperationId === operation.id ? "Posting..." : "Post labor"}
</button>
</div>
</div>
</td>
) : null}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -225,11 +647,11 @@ export function WorkOrderDetailPage() {
</section> </section>
{canManage ? ( {canManage ? (
<section className="grid gap-3 xl:grid-cols-2"> <section className="grid gap-3 xl:grid-cols-2">
<form onSubmit={handleIssueSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <form onSubmit={handleIssueSubmit} className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Issue</p> <p className="section-kicker">MATERIAL ISSUE</p>
<div className="mt-4 grid gap-3"> <div className="mt-3 grid gap-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Component</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: 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"> <select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: 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">
<option value="">Select component</option> <option value="">Select component</option>
{workOrder.materialRequirements.map((requirement) => ( {workOrder.materialRequirements.map((requirement) => (
@@ -239,7 +661,7 @@ export function WorkOrderDetailPage() {
</label> </label>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse</span>
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"> <select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => ( {[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option> <option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
@@ -247,7 +669,7 @@ export function WorkOrderDetailPage() {
</select> </select>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Location</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Location</span>
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: 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"> <select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: 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">
<option value="">Select location</option> <option value="">Select location</option>
{filteredLocationOptions.map((option) => ( {filteredLocationOptions.map((option) => (
@@ -256,29 +678,29 @@ export function WorkOrderDetailPage() {
</select> </select>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} 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>
</div> </div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, 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" /> <textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"> <button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isPostingIssue ? "Posting issue..." : "Post material issue"} {isPostingIssue ? "Posting issue..." : "Post material issue"}
</button> </button>
</div> </div>
</form> </form>
<form onSubmit={handleCompletionSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <form onSubmit={handleCompletionSubmit} className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Production Completion</p> <p className="section-kicker">PRODUCTION COMPLETION</p>
<div className="mt-4 grid gap-3"> <div className="mt-3 grid gap-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} 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>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, 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" /> <textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div> <div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
<button type="submit" disabled={isPostingCompletion} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"> <button type="submit" disabled={isPostingCompletion} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
@@ -288,12 +710,12 @@ export function WorkOrderDetailPage() {
</form> </form>
</section> </section>
) : null} ) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Requirements</p> <p className="section-kicker">MATERIAL REQUIREMENTS</p>
{workOrder.materialRequirements.length === 0 ? ( {workOrder.materialRequirements.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">This build item does not currently have BOM material requirements.</div> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This build item does not currently have BOM material requirements.</div>
) : ( ) : (
<div className="mt-5 overflow-hidden rounded-3xl border border-line/70"> <div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70"> <thead className="bg-page/70">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted"> <tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
@@ -324,14 +746,14 @@ export function WorkOrderDetailPage() {
)} )}
</section> </section>
<section className="grid gap-3 xl:grid-cols-2"> <section className="grid gap-3 xl:grid-cols-2">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Issue History</p> <p className="section-kicker">ISSUE HISTORY</p>
{workOrder.materialIssues.length === 0 ? ( {workOrder.materialIssues.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 material issues have been posted yet.</div> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No material issues have been posted yet.</div>
) : ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{workOrder.materialIssues.map((issue) => ( {workOrder.materialIssues.map((issue) => (
<div key={issue.id} className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3"> <div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div> <div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
@@ -346,14 +768,14 @@ export function WorkOrderDetailPage() {
</div> </div>
)} )}
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Completion History</p> <p className="section-kicker">COMPLETION HISTORY</p>
{workOrder.completions.length === 0 ? ( {workOrder.completions.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 production completions have been posted yet.</div> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No production completions have been posted yet.</div>
) : ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{workOrder.completions.map((completion) => ( {workOrder.completions.map((completion) => (
<div key={completion.id} className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3"> <div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="font-semibold text-text">{completion.quantity} completed</div> <div className="font-semibold text-text">{completion.quantity} completed</div>
<div className="text-xs text-muted">{completion.createdByName}</div> <div className="text-xs text-muted">{completion.createdByName}</div>
@@ -375,6 +797,48 @@ export function WorkOrderDetailPage() {
emptyMessage="No manufacturing attachments have been uploaded for this work order yet." emptyMessage="No manufacturing attachments have been uploaded for this work 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>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm manufacturing action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
extraFieldLabel={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Hold reason" : undefined}
extraFieldPlaceholder={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Explain the blocker forcing this work order onto hold." : undefined}
extraFieldValue={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? holdReasonDraft : undefined}
extraFieldRequired={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
extraFieldMultiline={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
onExtraFieldChange={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? setHoldReasonDraft : undefined}
isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
(pendingConfirmation?.kind === "completion" && isPostingCompletion)
}
onClose={() => {
if (!isUpdatingStatus && !isPostingIssue && !isPostingCompletion) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
await applyStatusChange(pendingConfirmation.nextStatus);
} else if (pendingConfirmation.kind === "issue") {
await submitIssue();
} else if (pendingConfirmation.kind === "completion") {
await submitCompletion();
}
setPendingConfirmation(null);
}}
/>
</section> </section>
); );
} }

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, useSearchParams } from "react-router-dom"; import { 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";
@@ -137,21 +137,24 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
} }
} }
function closeEditor() {
navigate(mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`);
}
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Editor</p> <p className="section-kicker">MANUFACTURING EDITOR</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Work Order" : "Edit Work Order"}</h3> <h3 className="module-title">{mode === "create" ? "NEW WORK ORDER" : "EDIT WORK ORDER"}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Create a build record for a manufactured item, assign it to a project when needed, and define where completed output should post.</p>
</div> </div>
<Link to={mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel Cancel
</Link> </button>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel space-y-3">
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span> <span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
@@ -195,7 +198,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
setItemPickerOpen(false); setItemPickerOpen(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"> }} 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">{option.sku}</div> <div className="font-semibold text-text">{option.sku}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.type} · {option.operationCount} ops</div> <div className="mt-1 text-xs text-muted">{option.name} - {option.type} - {option.operationCount} ops</div>
</button> </button>
))} ))}
</div> </div>
@@ -252,7 +255,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
setProjectPickerOpen(false); setProjectPickerOpen(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"> }} 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">{option.projectNumber}</div> <div className="font-semibold text-text">{option.projectNumber}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.customerName}</div> <div className="mt-1 text-xs text-muted">{option.name} - {option.customerName}</div>
</button> </button>
))} ))}
</div> </div>
@@ -292,9 +295,9 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span> <span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span>
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="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"> <div className="flex flex-col gap-2 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> <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"> <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 work order" : "Save changes"} {isSaving ? "Saving..." : mode === "create" ? "Create work order" : "Save changes"}
@@ -304,3 +307,4 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
</form> </form>
); );
} }

View File

@@ -35,13 +35,12 @@ export function WorkOrderListPage() {
}, [query, statusFilter, token]); }, [query, statusFilter, token]);
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing</p> <p className="section-kicker">MANUFACTURING</p>
<h3 className="mt-2 text-xl font-bold text-text">Work Orders</h3> <h3 className="module-title">WORK ORDERS</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Release and execute build work against manufactured or assembly inventory items, with project linkage and real inventory posting.</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link to="/manufacturing/work-orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"> <Link to="/manufacturing/work-orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
@@ -50,8 +49,8 @@ export function WorkOrderListPage() {
) : null} ) : null}
</div> </div>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px]"> <div className="grid gap-2.5 xl:grid-cols-[minmax(0,1fr)_240px]">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Search</span> <span className="mb-2 block text-sm font-semibold text-text">Search</span>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search work order, item, or project" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search work order, item, or project" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
@@ -63,12 +62,12 @@ export function WorkOrderListPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-3 rounded-[16px] border border-line/70 bg-page/70 px-3 py-2 text-sm text-muted">{status}</div>
</section> </section>
{workOrders.length === 0 ? ( {workOrders.length === 0 ? (
<div className="rounded-[28px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div> <div className="rounded-[20px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
) : ( ) : (
<div className="overflow-hidden rounded-[28px] border border-line/70 bg-surface/90 shadow-panel"> <div className="overflow-hidden rounded-[20px] border border-line/70 bg-surface/90 shadow-panel">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70"> <thead className="bg-page/70">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted"> <tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
@@ -107,3 +106,4 @@ export function WorkOrderListPage() {
</section> </section>
); );
} }

View File

@@ -1,16 +1,21 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { ProjectMilestoneStatus, WorkOrderSummaryDto } 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 { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js"; import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/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";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
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 { projectMilestoneStatusPalette } from "./config";
import { ProjectPriorityBadge } from "./ProjectPriorityBadge"; import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
import { ProjectStatusBadge } from "./ProjectStatusBadge"; import { ProjectStatusBadge } from "./ProjectStatusBadge";
function formatCurrency(value: number | null) {
return value === null ? "Not linked" : `$${value.toFixed(2)}`;
}
export function ProjectDetailPage() { export function ProjectDetailPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const { projectId } = useParams(); const { projectId } = useParams();
@@ -18,6 +23,7 @@ export function ProjectDetailPage() {
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]); const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null); const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const [status, setStatus] = useState("Loading project..."); const [status, setStatus] = useState("Loading project...");
const [updatingMilestoneId, setUpdatingMilestoneId] = useState<string | null>(null);
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false; const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
@@ -27,17 +33,17 @@ export function ProjectDetailPage() {
} }
api.getProject(token, projectId) api.getProject(token, projectId)
.then((nextProject) => { .then(async (nextProject) => {
setProject(nextProject); setProject(nextProject);
setStatus("Project loaded."); setStatus("Project loaded.");
if (nextProject.salesOrderId) { const [nextPlanning, nextWorkOrders] = await Promise.all([
api.getSalesOrderPlanning(token, nextProject.salesOrderId).then(setPlanning).catch(() => setPlanning(null)); nextProject.salesOrderId ? api.getSalesOrderPlanning(token, nextProject.salesOrderId).catch(() => null) : Promise.resolve(null),
} else { api.getWorkOrders(token, { projectId: nextProject.id }),
setPlanning(null); ]);
}
return api.getWorkOrders(token, { projectId: nextProject.id }); setPlanning(nextPlanning);
setWorkOrders(nextWorkOrders);
}) })
.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);
@@ -45,18 +51,116 @@ export function ProjectDetailPage() {
}, [projectId, token]); }, [projectId, token]);
if (!project) { if (!project) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="surface-panel text-sm text-muted">{status}</div>;
}
const sortedMilestones = [...project.milestones].sort((left, right) => {
if (left.status === "COMPLETE" && right.status !== "COMPLETE") {
return 1;
}
if (left.status !== "COMPLETE" && right.status === "COMPLETE") {
return -1;
}
if (left.dueDate && right.dueDate) {
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
}
if (left.dueDate) {
return -1;
}
if (right.dueDate) {
return 1;
}
return left.sortOrder - right.sortOrder;
});
const nextMilestone = sortedMilestones.find((milestone) => milestone.status !== "COMPLETE") ?? null;
const activeWorkOrders = workOrders.filter(
(workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD"
);
const nextWorkOrder = [...activeWorkOrders]
.sort((left, right) => {
if (left.dueDate && right.dueDate) {
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
}
if (left.dueDate) {
return -1;
}
if (right.dueDate) {
return 1;
}
return left.workOrderNumber.localeCompare(right.workOrderNumber);
})[0] ?? null;
const materialExceptionItems = planning
? planning.items.filter((item) => item.uncoveredQuantity > 0 || item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0).slice(0, 5)
: [];
const topBuildRecommendation = planning?.items.find((item) => item.recommendedBuildQuantity > 0) ?? null;
const topPurchaseRecommendation = planning?.items.find((item) => item.recommendedPurchaseQuantity > 0) ?? null;
const completionPercent = project.rollups.milestoneCount > 0
? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100)
: 0;
const readinessScore = project.cockpit.risk.readinessScore;
const riskTone = project.cockpit.risk.riskLevel === "LOW"
? "text-emerald-600 dark:text-emerald-300"
: project.cockpit.risk.riskLevel === "MEDIUM"
? "text-amber-600 dark:text-amber-300"
: "text-rose-600 dark:text-rose-300";
async function updateMilestoneStatus(milestoneId: string, nextStatus: ProjectMilestoneStatus) {
if (!token || !project) {
return;
}
setUpdatingMilestoneId(milestoneId);
setStatus("Updating milestone status...");
try {
const nextProject = await api.updateProjectMilestoneStatus(token, project.id, milestoneId, { status: nextStatus });
setProject(nextProject);
setStatus("Milestone status updated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update milestone status.";
setStatus(message);
} finally {
setUpdatingMilestoneId(null);
}
}
function milestoneQuickActions(currentStatus: ProjectMilestoneStatus) {
if (currentStatus === "PLANNED") {
return [
{ status: "IN_PROGRESS" as const, label: "Start" },
{ status: "BLOCKED" as const, label: "Block" },
{ status: "COMPLETE" as const, label: "Complete" },
];
}
if (currentStatus === "IN_PROGRESS") {
return [
{ status: "BLOCKED" as const, label: "Block" },
{ status: "COMPLETE" as const, label: "Complete" },
{ status: "PLANNED" as const, label: "Reset" },
];
}
if (currentStatus === "BLOCKED") {
return [
{ status: "IN_PROGRESS" as const, label: "Resume" },
{ status: "COMPLETE" as const, label: "Complete" },
{ status: "PLANNED" as const, label: "Reset" },
];
}
return [{ status: "IN_PROGRESS" as const, label: "Reopen" }];
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project</p> <p className="section-kicker">PROJECT</p>
<h3 className="mt-2 text-xl font-bold text-text">{project.projectNumber}</h3> <h3 className="module-title">{project.projectNumber}</h3>
<p className="mt-1 text-sm text-text">{project.name}</p> <p className="mt-1 text-sm text-text">{project.name}</p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-2.5 flex flex-wrap gap-2">
<ProjectStatusBadge status={project.status} /> <ProjectStatusBadge status={project.status} />
<ProjectPriorityBadge priority={project.priority} /> <ProjectPriorityBadge priority={project.priority} />
</div> </div>
@@ -68,121 +172,219 @@ export function ProjectDetailPage() {
</div> </div>
</div> </div>
<section className="grid gap-3 xl:grid-cols-4"> <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">Customer</p><div className="mt-2 text-base font-bold text-text">{project.customerName}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Customer</p><div className="mt-1 text-base font-bold text-text">{project.customerName}</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">Owner</p><div className="mt-2 text-base font-bold text-text">{project.ownerName || "Unassigned"}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Owner</p><div className="mt-1 text-base font-bold text-text">{project.ownerName || "Unassigned"}</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">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-1 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</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">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-1 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
</section>
<section className="grid gap-3 xl:grid-cols-4">
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.openMilestoneCount} open</div></article>
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div></article>
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Linked Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.workOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.activeWorkOrderCount} active</div></article>
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div></article>
</section>
<section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="section-kicker">PROJECT COCKPIT</p>
</div>
<div className="surface-panel-tight text-right">
<div className="metric-kicker">Milestone Progress</div>
<div className="mt-1 text-2xl font-bold text-text">{completionPercent}%</div>
</div>
</div>
<div className="mt-3 grid gap-2.5 xl:grid-cols-4">
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.commercial.activeDocumentTotal)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.commercial.activeDocumentNumber ? `${project.cockpit.commercial.activeDocumentNumber} - ${project.cockpit.commercial.activeDocumentStatus}` : "Link a quote or sales order"}</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Supply</p><div className="mt-2 text-base font-bold text-text">{planning ? planning.summary.uncoveredItemCount : project.cockpit.risk.shortageItemCount} shortage items</div><div className="mt-1 text-xs text-muted">{planning ? `Build ${planning.summary.totalBuildQuantity} - Buy ${planning.summary.totalPurchaseQuantity}` : `Uncovered qty ${project.cockpit.risk.totalUncoveredQuantity}`}</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Execution</p><div className="mt-2 text-base font-bold text-text">{project.rollups.activeWorkOrderCount} active work orders</div><div className="mt-1 text-xs text-muted">{nextWorkOrder ? `${nextWorkOrder.workOrderNumber} due ${nextWorkOrder.dueDate ? new Date(nextWorkOrder.dueDate).toLocaleDateString() : "unscheduled"}` : "No active work order due date"}</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Delivery</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.delivery.shipmentStatus ? project.cockpit.delivery.shipmentStatus.replaceAll("_", " ") : "Not linked"}</div><div className="mt-1 text-xs text-muted">{project.cockpit.delivery.shipmentNumber ? `${project.cockpit.delivery.shipmentNumber} - ${project.cockpit.delivery.packageCount} package(s)` : "Link a shipment to track delivery"}</div></article>
</div>
<div className="mt-3 grid gap-2.5 xl:grid-cols-3">
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchasing Coverage</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.purchasing.totalReceivedQuantity}/{project.cockpit.purchasing.totalOrderedQuantity} received</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.linkedPurchaseOrderCount} linked PO(s) - {project.cockpit.purchasing.totalOutstandingQuantity} outstanding</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Readiness Score</p><div className={`mt-2 text-base font-bold ${riskTone}`}>{readinessScore}%</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.riskLevel} risk - {project.cockpit.risk.shortageItemCount} shortage item(s)</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Spend</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.purchasing.linkedLineValue.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.vendorCount} vendor(s) across {project.cockpit.purchasing.linkedLineCount} linked line(s)</div></article>
</div>
<div className="mt-3 grid gap-2.5 xl:grid-cols-4">
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Booked Revenue</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.costs.bookedRevenue)}</div><div className="mt-1 text-xs text-muted">Quoted baseline {formatCurrency(project.cockpit.costs.quotedRevenue)}</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Commitment</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.linkedPurchaseCommitment.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Linked PO line value already committed</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Material Cost</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.plannedMaterialCost.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Issued so far ${project.cockpit.costs.issuedMaterialCost.toFixed(2)}</div></article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Load</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.costs.completedBuildQuantity}/{project.cockpit.costs.buildQuantity}</div><div className="mt-1 text-xs text-muted">{project.cockpit.costs.plannedOperationHours.toFixed(1)} planned operation hours</div></article>
</div>
<div className="mt-3 grid gap-2.5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Next Checkpoints</p>
<div className="mt-4 space-y-3">
<div className="rounded-[16px] border border-line/70 bg-surface/80 p-3"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone</div><div className="mt-2 font-semibold text-text">{nextMilestone ? nextMilestone.title : "All milestones complete"}</div><div className="mt-1 text-xs text-muted">{nextMilestone ? `${nextMilestone.status.replace("_", " ")} - ${nextMilestone.dueDate ? new Date(nextMilestone.dueDate).toLocaleDateString() : "No due date"}` : "No open milestone remains."}</div></div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 p-3"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Work Order</div><div className="mt-2 font-semibold text-text">{nextWorkOrder ? nextWorkOrder.workOrderNumber : "No active work orders"}</div><div className="mt-1 text-xs text-muted">{nextWorkOrder ? `${nextWorkOrder.itemSku} - ${nextWorkOrder.completedQuantity}/${nextWorkOrder.quantity} complete` : "Launch or link a work order to populate execution checkpoints."}</div></div>
</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Watchlist</p>
{materialExceptionItems.length === 0 ? <div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-surface/80 px-3 py-4 text-sm text-muted">No current build/buy exception items from linked sales-order planning.</div> : <div className="mt-4 space-y-3">{materialExceptionItems.map((item) => (<div key={item.itemId} className="rounded-[16px] border border-line/70 bg-surface/80 p-3"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{item.itemSku}</div><div className="mt-1 text-xs text-muted">{item.itemName}</div></div><div className="text-xs text-muted">Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}</div></div></div>))}</div>}
</article>
</div>
</section>
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<article className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div>
<p className="section-kicker">ACTIONABLE COCKPIT</p>
</div>
<Link to="/planning/workbench" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open workbench
</Link>
</div>
<div className="mt-3 grid gap-2.5 xl:grid-cols-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Follow-Through</p>
<div className="mt-2 text-base font-bold text-text">{topBuildRecommendation ? topBuildRecommendation.itemSku : "No build recommendation"}</div>
<div className="mt-1 text-xs text-muted">
{topBuildRecommendation ? `Recommended build qty ${topBuildRecommendation.recommendedBuildQuantity}` : "Planning does not currently recommend a new build."}
</div>
{topBuildRecommendation && project.salesOrderId ? (
<Link
to={`/manufacturing/work-orders/new?projectId=${project.id}&itemId=${topBuildRecommendation.itemId}&salesOrderId=${project.salesOrderId}&quantity=${topBuildRecommendation.recommendedBuildQuantity}&notes=${encodeURIComponent(`Project cockpit launch from ${project.projectNumber}`)}`}
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
>
Launch work order
</Link>
) : null}
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Follow-Through</p>
<div className="mt-2 text-base font-bold text-text">{topPurchaseRecommendation ? topPurchaseRecommendation.itemSku : "No buy recommendation"}</div>
<div className="mt-1 text-xs text-muted">
{topPurchaseRecommendation ? `Recommended buy qty ${topPurchaseRecommendation.recommendedPurchaseQuantity}` : "Planning does not currently recommend a new purchase."}
</div>
{topPurchaseRecommendation && project.salesOrderId ? (
<Link
to={`/purchasing/orders/new?planningOrderId=${project.salesOrderId}&itemId=${topPurchaseRecommendation.itemId}`}
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
>
Launch purchase order
</Link>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-3">
<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 project work order
</Link>
{project.salesOrderId ? (
<Link to={`/sales/orders/${project.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open sales 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">
Review purchasing
</Link>
</div>
</article>
<article className="surface-panel">
<div className="flex items-center justify-between gap-3"><div><p className="section-kicker">LINKED PURCHASING</p></div>{project.salesOrderId ? <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> : null}</div>
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-4 space-y-2.5">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[16px] border border-line/70 bg-page/60 px-3 py-2.5 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
</article>
<article className="surface-panel">
<p className="section-kicker">READINESS DRIVERS</p>
<div className="mt-3 space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Risk posture</div><div className={`mt-2 text-lg font-bold ${riskTone}`}>{project.cockpit.risk.riskLevel}</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.outstandingPurchaseOrderCount} PO(s) still waiting on receipts.</div></div>
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Blocked milestones: <span className="font-semibold">{project.cockpit.risk.blockedMilestoneCount}</span></div>
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Overdue execution items: <span className="font-semibold">{project.cockpit.risk.overdueMilestoneCount + project.cockpit.risk.overdueWorkOrderCount}</span></div>
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Uncovered material quantity: <span className="font-semibold">{project.cockpit.risk.totalUncoveredQuantity}</span></div>
</div>
</article>
</section>
<section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="surface-panel">
<p className="section-kicker">VENDOR EXPOSURE</p>
{project.cockpit.purchasing.vendors.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No supplier exposure exists until purchasing is linked.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (<div key={vendor.vendorId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{vendor.vendorName}</div><div className="mt-1 text-xs text-muted">{vendor.orderCount} linked order(s)</div></div><div className="text-right text-xs text-muted"><div>${vendor.linkedLineValue.toFixed(2)}</div><div>{vendor.outstandingQuantity} outstanding qty</div></div></div></div>))}</div>}
</article>
<article className="surface-panel">
<p className="section-kicker">RECENT RECEIPTS</p>
{project.cockpit.purchasing.recentReceipts.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No purchase receipts have been posted against linked project supply.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.recentReceipts.map((receipt) => (<div key={receipt.receiptId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{receipt.receiptNumber}</div><div className="mt-1 text-xs text-muted">{receipt.vendorName} - {receipt.purchaseOrderNumber}</div></div><div className="text-right text-xs text-muted"><div>{new Date(receipt.receivedAt).toLocaleDateString()}</div><div>{receipt.totalQuantity} units received</div></div></div></div>))}</div>}
</article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]"> <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"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p> <p className="section-kicker">CUSTOMER LINKAGE</p>
<dl className="mt-5 grid gap-3"> <dl className="mt-3 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"><Link to={`/crm/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</Link></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/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</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">{project.customerEmail}</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">{project.customerEmail}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
</dl> </dl>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Program Notes</p> <p className="section-kicker">PROGRAM NOTES</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
</article> </article>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Commercial + Delivery Links</p> <p className="section-kicker">COMMERCIAL + DELIVERY LINKS</p>
<div className="mt-5 grid gap-3 xl:grid-cols-3"> <div className="mt-3 grid gap-3 xl:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <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">Quote</div><div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div></div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</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">Sales Order</div><div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div></div>
<div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</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">Shipment</div><div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div></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">Sales Order</div>
<div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</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">Shipment</div>
<div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div>
</div>
</div> </div>
</section> </section>
<section className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div><p className="section-kicker">MILESTONES</p></div>
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
</div>
{project.milestones.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-3 space-y-2">{project.milestones.map((milestone) => (<div key={milestone.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div>{canManage ? <div className="flex flex-wrap gap-2">{milestoneQuickActions(milestone.status).map((action) => (<button key={action.status} type="button" onClick={() => void updateMilestoneStatus(milestone.id, action.status)} disabled={updatingMilestoneId === milestone.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">{updatingMilestoneId === milestone.id ? "Saving..." : action.label}</button>))}</div> : null}</div></div>))}</div>}
</section>
{planning ? ( {planning ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Readiness</p> <p className="section-kicker">MATERIAL READINESS</p>
<div className="mt-5 grid gap-3 xl:grid-cols-4"> <div className="mt-3 grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3"> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div></article>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div></article>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div></article>
</article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p><div className="mt-1 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div></article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div>
</article>
</div> </div>
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{planning.items {planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => (
.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0) <div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{item.itemSku}</div><div className="mt-1 text-xs text-muted">{item.itemName}</div></div><div className="text-sm text-muted">Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}</div></div></div>
.slice(0, 8) ))}
.map((item) => (
<div key={item.itemId} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</div>
<div className="text-sm text-muted">
Build {item.recommendedBuildQuantity} · Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
</div>
</div>
</div>
))}
</div> </div>
</section> </section>
) : null} ) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div><p className="section-kicker">MANUFACTURING LINKS</p></div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Links</p> {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}
<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> </div>
{workOrders.length === 0 ? ( {workOrders.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-3 space-y-2">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 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>}
<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> </section>
<section className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div><p className="section-kicker">ACTIVITY TIMELINE</p></div>
</div>
{project.timeline.length === 0 ? (
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No timeline activity is available for this project yet.</div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{workOrders.map((workOrder) => ( {project.timeline.map((entry) => (
<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 key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<div className="font-semibold text-text">{workOrder.workOrderNumber}</div> <div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
<div className="mt-1 text-xs text-muted">{workOrder.itemSku} · {workOrder.completedQuantity}/{workOrder.quantity} complete</div> <div className="mt-1 font-semibold text-text">
{entry.href ? <Link to={entry.href} className="hover:text-brand">{entry.title}</Link> : entry.title}
</div>
<div className="mt-1 text-sm text-muted">{entry.detail}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{new Date(entry.createdAt).toLocaleString()}</div>
<div>{entry.actorName || "System"}</div>
</div> </div>
<div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div>
</div> </div>
</Link> </div>
))} ))}
</div> </div>
)} )}
</section> </section>
<FileAttachmentsPanel <FileAttachmentsPanel ownerType="PROJECT" ownerId={project.id} eyebrow="Project Documents" title="Program file hub" description="Store drawings, revision references, correspondence, and support files directly on the project record." emptyMessage="No project files have been uploaded yet." />
ownerType="PROJECT"
ownerId={project.id}
eyebrow="Project Documents"
title="Program file hub"
description="Store drawings, revision references, correspondence, and support files directly on the project record."
emptyMessage="No project files have been uploaded 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

@@ -2,15 +2,23 @@ import type {
ProjectCustomerOptionDto, ProjectCustomerOptionDto,
ProjectDocumentOptionDto, ProjectDocumentOptionDto,
ProjectInput, ProjectInput,
ProjectMilestoneInput,
ProjectOwnerOptionDto, ProjectOwnerOptionDto,
ProjectShipmentOptionDto, ProjectShipmentOptionDto,
} from "@mrp/shared/dist/projects/types.js"; } from "@mrp/shared/dist/projects/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config"; import { emptyProjectInput, projectMilestoneStatusOptions, projectPriorityOptions, projectStatusOptions } from "./config";
type ProjectPendingConfirmation =
| { kind: "change-customer"; customerId: string; customerName: string }
| { kind: "unlink-quote" }
| { kind: "unlink-order" }
| { kind: "unlink-shipment" };
export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) { export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const { token, user } = useAuth(); const { token, user } = useAuth();
@@ -34,6 +42,14 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const [shipmentPickerOpen, setShipmentPickerOpen] = 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);
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
function reindexMilestones(milestones: ProjectMilestoneInput[]) {
return milestones.map((milestone, index) => ({
...milestone,
sortOrder: index * 10,
}));
}
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -75,6 +91,14 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
ownerId: project.ownerId, ownerId: project.ownerId,
dueDate: project.dueDate, dueDate: project.dueDate,
notes: project.notes, notes: project.notes,
milestones: project.milestones.map((milestone) => ({
id: milestone.id,
title: milestone.title,
status: milestone.status,
dueDate: milestone.dueDate,
notes: milestone.notes,
sortOrder: milestone.sortOrder,
})),
}); });
setCustomerSearchTerm(project.customerName); setCustomerSearchTerm(project.customerName);
setOwnerSearchTerm(project.ownerName ?? ""); setOwnerSearchTerm(project.ownerName ?? "");
@@ -103,6 +127,43 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
})); }));
} }
function hasLinkedCommercialRecords() {
return Boolean(form.salesQuoteId || form.salesOrderId || form.shipmentId);
}
function applyCustomerSelection(customerId: string, customerName: string) {
updateField("customerId", customerId);
setCustomerSearchTerm(customerName);
setCustomerPickerOpen(false);
}
function requestCustomerSelection(customerId: string, customerName: string) {
if (form.customerId && form.customerId !== customerId && hasLinkedCommercialRecords()) {
setPendingConfirmation({ kind: "change-customer", customerId, customerName });
return;
}
applyCustomerSelection(customerId, customerName);
}
function unlinkQuote() {
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
}
function unlinkOrder() {
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
}
function unlinkShipment() {
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
}
function restoreSearchTerms() { function restoreSearchTerms() {
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId); const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId); const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
@@ -117,6 +178,44 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? ""); setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
} }
function addMilestone() {
setForm((current) => ({
...current,
milestones: reindexMilestones([
...current.milestones,
{
id: null,
title: "",
status: "PLANNED",
dueDate: current.dueDate,
notes: "",
sortOrder: current.milestones.length * 10,
},
]),
}));
}
function updateMilestone<Key extends keyof ProjectMilestoneInput>(index: number, key: Key, value: ProjectMilestoneInput[Key]) {
setForm((current) => ({
...current,
milestones: current.milestones.map((milestone, milestoneIndex) =>
milestoneIndex === index
? {
...milestone,
[key]: value,
}
: milestone
),
}));
}
function removeMilestone(index: number) {
setForm((current) => ({
...current,
milestones: reindexMilestones(current.milestones.filter((_, milestoneIndex) => milestoneIndex !== index)),
}));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -136,20 +235,19 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
} }
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects Editor</p> <p className="section-kicker">PROJECTS EDITOR</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Project" : "Edit Project"}</h3> <h3 className="module-title">{mode === "create" ? "New Project" : "Edit Project"}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Create a customer-linked program record that can anchor commercial documents, delivery work, and project files.</p>
</div> </div>
<Link to={mode === "create" ? "/projects" : `/projects/${projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <Link to={mode === "create" ? "/projects" : `/projects/${projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel Cancel
</Link> </Link>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="space-y-3 surface-panel">
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Project name</span> <span className="mb-2 block text-sm font-semibold text-text">Project name</span>
@@ -158,13 +256,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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>
<div className="relative"> <div className="relative">
<input <input
value={customerSearchTerm} value={customerSearchTerm}
onChange={(event) => { onChange={(event) => {
setCustomerSearchTerm(event.target.value); setCustomerSearchTerm(event.target.value);
updateField("customerId", ""); setCustomerPickerOpen(true);
setCustomerPickerOpen(true); }}
}}
onFocus={() => setCustomerPickerOpen(true)} onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => window.setTimeout(() => { onBlur={() => window.setTimeout(() => {
setCustomerPickerOpen(false); setCustomerPickerOpen(false);
@@ -187,9 +284,7 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
.map((customer) => ( .map((customer) => (
<button key={customer.id} type="button" onMouseDown={(event) => { <button key={customer.id} type="button" onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
updateField("customerId", customer.id); requestCustomerSelection(customer.id, customer.name);
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"> }} 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="font-semibold text-text">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div> <div className="mt-1 text-xs text-muted">{customer.email}</div>
@@ -274,13 +369,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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>
<div className="relative"> <div className="relative">
<input <input
value={quoteSearchTerm} value={quoteSearchTerm}
onChange={(event) => { onChange={(event) => {
setQuoteSearchTerm(event.target.value); setQuoteSearchTerm(event.target.value);
updateField("salesQuoteId", null); setQuotePickerOpen(true);
setQuotePickerOpen(true); }}
}}
onFocus={() => setQuotePickerOpen(true)} onFocus={() => setQuotePickerOpen(true)}
onBlur={() => window.setTimeout(() => { onBlur={() => window.setTimeout(() => {
setQuotePickerOpen(false); setQuotePickerOpen(false);
@@ -293,9 +387,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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"> <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) => { <button type="button" onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
updateField("salesQuoteId", null); if (form.salesQuoteId) {
setQuoteSearchTerm(""); setPendingConfirmation({ kind: "unlink-quote" });
setQuotePickerOpen(false); } else {
unlinkQuote();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70"> }} 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> <div className="font-semibold text-text">No linked quote</div>
</button> </button>
@@ -326,13 +422,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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>
<div className="relative"> <div className="relative">
<input <input
value={orderSearchTerm} value={orderSearchTerm}
onChange={(event) => { onChange={(event) => {
setOrderSearchTerm(event.target.value); setOrderSearchTerm(event.target.value);
updateField("salesOrderId", null); setOrderPickerOpen(true);
setOrderPickerOpen(true); }}
}}
onFocus={() => setOrderPickerOpen(true)} onFocus={() => setOrderPickerOpen(true)}
onBlur={() => window.setTimeout(() => { onBlur={() => window.setTimeout(() => {
setOrderPickerOpen(false); setOrderPickerOpen(false);
@@ -345,9 +440,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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"> <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) => { <button type="button" onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
updateField("salesOrderId", null); if (form.salesOrderId) {
setOrderSearchTerm(""); setPendingConfirmation({ kind: "unlink-order" });
setOrderPickerOpen(false); } else {
unlinkOrder();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70"> }} 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> <div className="font-semibold text-text">No linked sales order</div>
</button> </button>
@@ -378,13 +475,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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>
<div className="relative"> <div className="relative">
<input <input
value={shipmentSearchTerm} value={shipmentSearchTerm}
onChange={(event) => { onChange={(event) => {
setShipmentSearchTerm(event.target.value); setShipmentSearchTerm(event.target.value);
updateField("shipmentId", null); setShipmentPickerOpen(true);
setShipmentPickerOpen(true); }}
}}
onFocus={() => setShipmentPickerOpen(true)} onFocus={() => setShipmentPickerOpen(true)}
onBlur={() => window.setTimeout(() => { onBlur={() => window.setTimeout(() => {
setShipmentPickerOpen(false); setShipmentPickerOpen(false);
@@ -397,9 +493,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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"> <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) => { <button type="button" onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
updateField("shipmentId", null); if (form.shipmentId) {
setShipmentSearchTerm(""); setPendingConfirmation({ kind: "unlink-shipment" });
setShipmentPickerOpen(false); } else {
unlinkShipment();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70"> }} 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> <div className="font-semibold text-text">No linked shipment</div>
</button> </button>
@@ -430,8 +528,78 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</div> </div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <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={5} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold text-text">Milestones</div>
<div className="mt-1 text-xs text-muted">Track checkpoints, due dates, and blocked work inside the project record.</div>
</div>
<button type="button" onClick={addMilestone} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add milestone
</button>
</div>
{form.milestones.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-surface/80 px-3 py-4 text-sm text-muted">
No milestones added yet.
</div>
) : (
<div className="space-y-3">
{form.milestones.map((milestone, milestoneIndex) => (
<div key={milestone.id ?? `new-milestone-${milestoneIndex}`} className="rounded-[18px] border border-line/70 bg-surface/80 p-3">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_180px_180px]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone</span>
<input
value={milestone.title}
onChange={(event) => updateMilestone(milestoneIndex, "title", 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"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={milestone.status}
onChange={(event) => updateMilestone(milestoneIndex, "status", event.target.value as ProjectMilestoneInput["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{projectMilestoneStatusOptions.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">Due date</span>
<input
type="date"
value={milestone.dueDate ? milestone.dueDate.slice(0, 10) : ""}
onChange={(event) => updateMilestone(milestoneIndex, "dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)}
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>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_120px]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<textarea
value={milestone.notes}
onChange={(event) => updateMilestone(milestoneIndex, "notes", event.target.value)}
rows={3}
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<div className="flex items-end">
<button type="button" onClick={() => removeMilestone(milestoneIndex)} className="w-full 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>
<div className="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"> <div className="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> <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"> <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">
@@ -439,6 +607,61 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</button> </button>
</div> </div>
</section> </section>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={
pendingConfirmation?.kind === "change-customer"
? "Change project customer"
: pendingConfirmation?.kind === "unlink-quote"
? "Remove linked quote"
: pendingConfirmation?.kind === "unlink-order"
? "Remove linked sales order"
: "Remove linked shipment"
}
description={
pendingConfirmation?.kind === "change-customer"
? `Switch this project to ${pendingConfirmation.customerName}. Existing quote, sales order, and shipment links will be cleared.`
: pendingConfirmation?.kind === "unlink-quote"
? "Remove the currently linked quote from this project draft."
: pendingConfirmation?.kind === "unlink-order"
? "Remove the currently linked sales order from this project draft."
: "Remove the currently linked shipment from this project draft."
}
impact={
pendingConfirmation?.kind === "change-customer"
? "Commercial and delivery linkage tied to the previous customer will be cleared immediately from the draft."
: "The project will no longer point to that related record after you save this edit."
}
recovery={
pendingConfirmation?.kind === "change-customer"
? "Re-link the correct quote, order, and shipment before saving if the customer change was accidental."
: "Pick the related record again before saving if this unlink was a mistake."
}
confirmLabel={
pendingConfirmation?.kind === "change-customer"
? "Change customer"
: "Remove link"
}
onClose={() => setPendingConfirmation(null)}
onConfirm={() => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "change-customer") {
applyCustomerSelection(pendingConfirmation.customerId, pendingConfirmation.customerName);
} else if (pendingConfirmation.kind === "unlink-quote") {
unlinkQuote();
} else if (pendingConfirmation.kind === "unlink-order") {
unlinkOrder();
} else if (pendingConfirmation.kind === "unlink-shipment") {
unlinkShipment();
}
setPendingConfirmation(null);
}}
/>
</form> </form>
); );
} }

View File

@@ -42,13 +42,12 @@ export function ProjectListPage() {
}, [priorityFilter, query, statusFilter, token]); }, [priorityFilter, query, statusFilter, token]);
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects</p> <p className="section-kicker">PROJECTS</p>
<h3 className="mt-2 text-xl font-bold text-text">Program records</h3> <h3 className="module-title">PROGRAM RECORDS</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Track long-running customer programs across commercial commitments, shipment deliverables, ownership, and due dates.</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link to="/projects/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"> <Link to="/projects/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
@@ -57,8 +56,8 @@ export function ProjectListPage() {
) : null} ) : null}
</div> </div>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]"> <div className="grid gap-2.5 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Search</span> <span className="mb-2 block text-sm font-semibold text-text">Search</span>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Project number, name, customer" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Project number, name, customer" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
@@ -76,16 +75,17 @@ export function ProjectListPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-3 rounded-[16px] border border-line/70 bg-page/70 px-3 py-2 text-sm text-muted">{status}</div>
{projects.length === 0 ? ( {projects.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 projects are available for the current filters.</div> <div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No projects are available for the current filters.</div>
) : ( ) : (
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
<th className="px-2 py-2">Project</th> <th className="px-2 py-2">Project</th>
<th className="px-2 py-2">Customer</th> <th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Rollups</th>
<th className="px-2 py-2">Owner</th> <th className="px-2 py-2">Owner</th>
<th className="px-2 py-2">Status</th> <th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Priority</th> <th className="px-2 py-2">Priority</th>
@@ -100,6 +100,11 @@ export function ProjectListPage() {
<div className="mt-1 text-xs text-muted">{project.name}</div> <div className="mt-1 text-xs text-muted">{project.name}</div>
</td> </td>
<td className="px-2 py-2 text-muted">{project.customerName}</td> <td className="px-2 py-2 text-muted">{project.customerName}</td>
<td className="px-2 py-2 text-xs text-muted">
<div>{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount} milestones</div>
<div>{project.rollups.activeWorkOrderCount} active WO</div>
<div>{project.rollups.overdueMilestoneCount + project.rollups.overdueWorkOrderCount} overdue items</div>
</td>
<td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td> <td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td>
<td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td> <td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td>
<td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td> <td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td>
@@ -114,3 +119,4 @@ export function ProjectListPage() {
</section> </section>
); );
} }

View File

@@ -1,4 +1,4 @@
import type { ProjectInput, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js"; import type { ProjectInput, ProjectMilestoneStatus, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js";
export const projectStatusOptions: Array<{ value: ProjectStatus; label: string }> = [ export const projectStatusOptions: Array<{ value: ProjectStatus; label: string }> = [
{ value: "PLANNED", label: "Planned" }, { value: "PLANNED", label: "Planned" },
@@ -40,6 +40,20 @@ export const projectPriorityPalette: Record<ProjectPriority, string> = {
CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300", CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
}; };
export const projectMilestoneStatusOptions: Array<{ value: ProjectMilestoneStatus; label: string }> = [
{ value: "PLANNED", label: "Planned" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "BLOCKED", label: "Blocked" },
{ value: "COMPLETE", label: "Complete" },
];
export const projectMilestoneStatusPalette: Record<ProjectMilestoneStatus, string> = {
PLANNED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
IN_PROGRESS: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
BLOCKED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
COMPLETE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const emptyProjectInput: ProjectInput = { export const emptyProjectInput: ProjectInput = {
name: "", name: "",
status: "PLANNED", status: "PLANNED",
@@ -51,4 +65,5 @@ export const emptyProjectInput: ProjectInput = {
ownerId: null, ownerId: null,
dueDate: null, dueDate: null,
notes: "", notes: "",
milestones: [],
}; };

View File

@@ -7,11 +7,68 @@ 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 { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel"; 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";
function formatCurrency(value: number) {
return `$${value.toFixed(2)}`;
}
function mapPurchaseDocumentForComparison(
document: Pick<
PurchaseOrderDetailDto,
| "documentNumber"
| "vendorName"
| "status"
| "issueDate"
| "taxPercent"
| "taxAmount"
| "freightAmount"
| "subtotal"
| "total"
| "notes"
| "paymentTerms"
| "currencyCode"
| "lines"
| "receipts"
>
) {
return {
title: document.documentNumber,
subtitle: document.vendorName,
status: document.status,
metaFields: [
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
{ label: "Payment Terms", value: document.paymentTerms || "N/A" },
{ label: "Currency", value: document.currencyCode || "USD" },
{ label: "Receipts", value: document.receipts.length.toString() },
],
totalFields: [
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
{ label: "Freight", value: formatCurrency(document.freightAmount) },
{ label: "Total", value: formatCurrency(document.total) },
],
notes: document.notes,
lines: document.lines.map((line) => ({
key: line.id || `${line.itemId}-${line.position}`,
title: `${line.itemSku} | ${line.itemName}`,
subtitle: line.description,
quantity: `${line.quantity} ${line.unitOfMeasure}`,
unitLabel: line.unitOfMeasure,
amountLabel: formatCurrency(line.unitCost),
totalLabel: formatCurrency(line.lineTotal),
extraLabel:
`${line.receivedQuantity} received | ${line.remainingQuantity} remaining` +
(line.salesOrderNumber ? ` | Demand ${line.salesOrderNumber}` : ""),
})),
};
}
export function PurchaseDetailPage() { export function PurchaseDetailPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const { orderId } = useParams(); const { orderId } = useParams();
@@ -25,6 +82,20 @@ export function PurchaseDetailPage() {
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isOpeningPdf, setIsOpeningPdf] = useState(false); const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null); const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "receipt";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus?: PurchaseOrderStatus;
}
| null
>(null);
const canManage = user?.permissions.includes("purchasing.write") ?? false; const canManage = user?.permissions.includes("purchasing.write") ?? false;
const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false); const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false);
@@ -88,7 +159,7 @@ export function PurchaseDetailPage() {
}, [document]); }, [document]);
if (!document) { if (!document) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
const activeDocument = document; const activeDocument = document;
@@ -107,7 +178,7 @@ export function PurchaseDetailPage() {
})); }));
} }
async function handleStatusChange(nextStatus: PurchaseOrderStatus) { async function applyStatusChange(nextStatus: PurchaseOrderStatus) {
if (!token) { if (!token) {
return; return;
} }
@@ -118,7 +189,7 @@ export function PurchaseDetailPage() {
try { try {
const nextDocument = await api.updatePurchaseOrderStatus(token, activeDocument.id, nextStatus); const nextDocument = await api.updatePurchaseOrderStatus(token, activeDocument.id, nextStatus);
setDocument(nextDocument); setDocument(nextDocument);
setStatus("Purchase order status updated."); setStatus("Purchase order status updated. Confirm vendor communication and receiving expectations if this moved the order into a terminal state.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update purchase order status."; const message = error instanceof ApiError ? error.message : "Unable to update purchase order status.";
setStatus(message); setStatus(message);
@@ -127,8 +198,7 @@ export function PurchaseDetailPage() {
} }
} }
async function handleReceiptSubmit(event: React.FormEvent<HTMLFormElement>) { async function applyReceipt() {
event.preventDefault();
if (!token || !canReceive) { if (!token || !canReceive) {
return; return;
} }
@@ -155,7 +225,7 @@ export function PurchaseDetailPage() {
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
notes: "", notes: "",
})); }));
setReceiptStatus("Purchase receipt recorded."); setReceiptStatus("Purchase receipt recorded. Inventory has been increased; verify stock balances and post a correcting movement if quantities were overstated.");
setStatus("Purchase order updated after receipt."); setStatus("Purchase order updated after receipt.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to record purchase receipt."; const message = error instanceof ApiError ? error.message : "Unable to record purchase receipt.";
@@ -165,6 +235,39 @@ export function PurchaseDetailPage() {
} }
} }
function handleStatusChange(nextStatus: PurchaseOrderStatus) {
const label = purchaseStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
setPendingConfirmation({
kind: "status",
title: `Set purchase order to ${label}`,
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
impact:
nextStatus === "CLOSED"
? "This closes the order operationally and can change inbound supply expectations, shortage coverage, and vendor follow-up."
: "This changes the purchasing state used by receiving, planning, and audit review.",
recovery: "If the status is wrong, set the order back to the correct state and verify any downstream receiving or planning assumptions.",
confirmLabel: `Set ${label}`,
confirmationLabel: nextStatus === "CLOSED" ? "Type purchase order number to confirm:" : undefined,
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
nextStatus,
});
}
function handleReceiptSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const totalReceiptQuantity = openLines.reduce((sum, line) => sum + Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)), 0);
setPendingConfirmation({
kind: "receipt",
title: "Post purchase receipt",
description: `Receive ${totalReceiptQuantity} total units into ${receiptForm.warehouseId && receiptForm.locationId ? "the selected stock location" : "inventory"} for ${activeDocument.documentNumber}.`,
impact: "This increases inventory immediately and becomes part of the PO receipt history.",
recovery: "If quantities are wrong, post the correcting inventory movement and review the remaining quantities on the purchase order.",
confirmLabel: "Post receipt",
confirmationLabel: totalReceiptQuantity > 0 ? "Type purchase order number to confirm:" : undefined,
confirmationValue: totalReceiptQuantity > 0 ? activeDocument.documentNumber : undefined,
});
}
async function handleOpenPdf() { async function handleOpenPdf() {
if (!token) { if (!token) {
return; return;
@@ -189,14 +292,17 @@ export function PurchaseDetailPage() {
return ( return (
<section className="space-y-4"> <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="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Order</p> <p className="section-kicker">PURCHASE ORDER</p>
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3> <h3 className="module-title">{activeDocument.documentNumber}</h3>
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p> <p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<PurchaseStatusBadge status={activeDocument.status} /> <PurchaseStatusBadge status={activeDocument.status} />
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
Rev {activeDocument.revisions[0]?.revisionNumber ?? 0}
</span>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -220,11 +326,10 @@ export function PurchaseDetailPage() {
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p> <p className="section-kicker">QUICK ACTIONS</p>
<p className="mt-2 text-sm text-muted">Update purchase-order status without opening the full editor.</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{purchaseStatusOptions.map((option) => ( {purchaseStatusOptions.map((option) => (
@@ -236,43 +341,115 @@ export function PurchaseDetailPage() {
</div> </div>
</section> </section>
) : null} ) : null}
<section className="grid gap-3 xl:grid-cols-4"> <section className="grid gap-2 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="surface-panel-tight"><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="surface-panel-tight"><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">Receipts</p><div className="mt-2 text-base font-bold text-text">{activeDocument.receipts.length}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Receipts</p><div className="mt-2 text-base font-bold text-text">{activeDocument.receipts.length}</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">Qty Remaining</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}</div></article> <article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Qty Remaining</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}</div></article>
</section> </section>
<section className="grid gap-3 xl:grid-cols-4"> <section className="grid gap-2 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">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div></article> <article className="surface-panel-tight"><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> <article className="surface-panel-tight"><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>
<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="surface-panel-tight"><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="surface-panel-tight"><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="surface-panel-tight"><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> <article className="surface-panel-tight"><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> </section>
<section className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div>
<p className="section-kicker">REVISION HISTORY</p>
</div>
</div>
{activeDocument.revisions.length === 0 ? (
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No revisions recorded yet.
</div>
) : (
<div className="mt-3 space-y-2">
{activeDocument.revisions.map((revision) => (
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
<div className="mt-1 text-sm text-text">{revision.reason}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{new Date(revision.createdAt).toLocaleString()}</div>
<div className="mt-1">{revision.createdByName ?? "System"}</div>
</div>
</div>
</article>
))}
</div>
)}
</section>
{activeDocument.revisions.length > 0 ? (
<DocumentRevisionComparison
title="Revision Comparison"
description="Compare earlier purchase-order revisions against the current document or another revision to review commercial, receiving, and line-level changes."
currentLabel="Current document"
currentDocument={mapPurchaseDocumentForComparison(activeDocument)}
revisions={activeDocument.revisions.map((revision) => ({
id: revision.id,
label: `Rev ${revision.revisionNumber}`,
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
}))}
getRevisionDocument={(revisionId) => {
if (revisionId === "current") {
return mapPurchaseDocumentForComparison(activeDocument);
}
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
if (!revision) {
return mapPurchaseDocumentForComparison(activeDocument);
}
return mapPurchaseDocumentForComparison({
...revision.snapshot,
lines: revision.snapshot.lines.map((line) => ({
id: `${line.itemId}-${line.position}`,
...line,
})),
receipts: revision.snapshot.receipts,
});
}}
/>
) : null}
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]"> <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"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor</p> <p className="section-kicker">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"><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">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>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p> <p className="section-kicker">PROJECT LINK</p>
{activeDocument.projectId ? (
<div className="mt-3 space-y-2">
<Link to={`/projects/${activeDocument.projectId}`} className="inline-flex items-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text hover:bg-page/70">
{activeDocument.projectNumber} / {activeDocument.projectName}
</Link>
<p className="text-sm text-muted">Project cockpit and rollups use this linkage.</p>
</div>
) : (
<p className="mt-3 text-sm text-muted">No linked project.</p>
)}
<p className="mt-4 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> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
</article> </article>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Context</p> <p className="section-kicker">DEMAND CONTEXT</p>
{demandContextItems.length === 0 ? ( {demandContextItems.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No active shared shortage or buy-signal records currently point at items on this purchase order. No active shortage or buy-signal context for these items.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{demandContextItems.map((item) => ( {demandContextItems.map((item) => (
<div key={item.itemId} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">{item.itemSku}</div> <div className="font-semibold text-text">{item.itemSku}</div>
@@ -287,12 +464,12 @@ export function PurchaseDetailPage() {
</div> </div>
)} )}
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p> <p className="section-kicker">LINE ITEMS</p>
{activeDocument.lines.length === 0 ? ( {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-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No line items added yet.</div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-3 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <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">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</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> <tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</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>
@@ -320,16 +497,14 @@ export function PurchaseDetailPage() {
</section> </section>
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]"> <section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
{canReceive ? ( {canReceive ? (
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Receiving</p> <p className="section-kicker">PURCHASE RECEIVING</p>
<h4 className="mt-2 text-lg font-bold text-text">Receive material</h4>
<p className="mt-2 text-sm text-muted">Post received quantities to inventory and retain a receipt record against this order.</p>
{openLines.length === 0 ? ( {openLines.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"> <div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
All ordered quantities have been received for this purchase order. All ordered quantities have been received for this purchase order.
</div> </div>
) : ( ) : (
<form className="mt-5 space-y-4" onSubmit={handleReceiptSubmit}> <form className="mt-3 space-y-3" onSubmit={handleReceiptSubmit}>
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Receipt date</span> <span className="mb-2 block text-sm font-semibold text-text">Receipt date</span>
@@ -367,12 +542,12 @@ export function PurchaseDetailPage() {
value={receiptForm.notes} value={receiptForm.notes}
onChange={(event) => updateReceiptField("notes", event.target.value)} onChange={(event) => updateReceiptField("notes", event.target.value)}
rows={3} 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" className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/> />
</label> </label>
<div className="space-y-3"> <div className="space-y-3">
{openLines.map((line) => ( {openLines.map((line) => (
<div key={line.id} className="grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[minmax(0,1.3fr)_0.6fr_0.7fr_0.7fr]"> <div key={line.id} className="grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[minmax(0,1.3fr)_0.6fr_0.7fr_0.7fr]">
<div> <div>
<div className="font-semibold text-text">{line.itemSku}</div> <div className="font-semibold text-text">{line.itemSku}</div>
<div className="mt-1 text-xs text-muted">{line.itemName}</div> <div className="mt-1 text-xs text-muted">{line.itemName}</div>
@@ -401,7 +576,7 @@ export function PurchaseDetailPage() {
</div> </div>
))} ))}
</div> </div>
<div className="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"> <div className="flex flex-col gap-2 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">{receiptStatus}</span> <span className="min-w-0 text-sm text-muted">{receiptStatus}</span>
<button <button
type="submit" type="submit"
@@ -415,17 +590,16 @@ export function PurchaseDetailPage() {
)} )}
</article> </article>
) : null} ) : null}
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Receipt History</p> <p className="section-kicker">RECEIPT HISTORY</p>
<h4 className="mt-2 text-lg font-bold text-text">Received material log</h4>
{activeDocument.receipts.length === 0 ? ( {activeDocument.receipts.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No purchase receipts have been recorded for this order yet. No purchase receipts recorded yet.
</div> </div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{activeDocument.receipts.map((receipt) => ( {activeDocument.receipts.map((receipt) => (
<article key={receipt.id} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <article key={receipt.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<div className="text-sm font-semibold text-text">{receipt.receiptNumber}</div> <div className="text-sm font-semibold text-text">{receipt.receiptNumber}</div>
@@ -464,6 +638,42 @@ export function PurchaseDetailPage() {
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet." 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>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm purchasing action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "receipt" && isSavingReceipt)
}
onClose={() => {
if (!isUpdatingStatus && !isSavingReceipt) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
await applyStatusChange(pendingConfirmation.nextStatus);
setPendingConfirmation(null);
return;
}
if (pendingConfirmation.kind === "receipt") {
await applyReceipt();
setPendingConfirmation(null);
}
}}
/>
</section> </section>
); );
} }

View File

@@ -1,7 +1,8 @@
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared"; import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { inventoryUnitOptions } from "../inventory/config"; import { inventoryUnitOptions } from "../inventory/config";
@@ -13,6 +14,9 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { orderId } = useParams(); const { orderId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId"); const seededVendorId = searchParams.get("vendorId");
const seededProjectId = searchParams.get("projectId");
const seededProjectNumber = searchParams.get("projectNumber");
const seededProjectName = searchParams.get("projectName");
const planningOrderId = searchParams.get("planningOrderId"); const planningOrderId = searchParams.get("planningOrderId");
const selectedPlanningItemId = searchParams.get("itemId"); const selectedPlanningItemId = searchParams.get("itemId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput); const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
@@ -24,6 +28,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]); const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null); const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] { function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] {
const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : []; const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : [];
@@ -55,6 +60,12 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
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([]));
}, [mode, seededVendorId, token]); }, [mode, seededVendorId, token]);
useEffect(() => {
if (mode === "create" && seededProjectId) {
setForm((current) => ({ ...current, projectId: current.projectId || seededProjectId }));
}
}, [mode, seededProjectId]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) { if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
return; return;
@@ -64,7 +75,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
.then((planning: SalesOrderPlanningDto) => { .then((planning: SalesOrderPlanningDto) => {
const recommendedNodes = planning.lines.flatMap((line) => const recommendedNodes = planning.lines.flatMap((line) =>
collectRecommendedPurchaseNodes(line.rootNode).map((node) => ({ collectRecommendedPurchaseNodes(line.rootNode).map((node) => ({
salesOrderLineId: line.lineId, salesOrderLineId: node.itemId === line.itemId ? line.lineId : null,
...node, ...node,
})) }))
); );
@@ -101,6 +112,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
setForm((current) => ({ setForm((current) => ({
...current, ...current,
vendorId: current.vendorId || autoVendorId || "", vendorId: current.vendorId || autoVendorId || "",
projectId: current.projectId || seededProjectId || null,
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`, notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
lines: current.lines.length > 0 ? current.lines : recommendedLines, lines: current.lines.length > 0 ? current.lines : recommendedLines,
})); }));
@@ -122,7 +134,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
.catch(() => { .catch(() => {
setStatus("Unable to load demand-planning recommendations."); setStatus("Unable to load demand-planning recommendations.");
}); });
}, [itemOptions, mode, planningOrderId, seededVendorId, selectedPlanningItemId, token, vendors]); }, [itemOptions, mode, planningOrderId, seededProjectId, seededVendorId, selectedPlanningItemId, token, vendors]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "edit" || !orderId) { if (!token || mode !== "edit" || !orderId) {
@@ -133,11 +145,13 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
.then((document) => { .then((document) => {
setForm({ setForm({
vendorId: document.vendorId, vendorId: document.vendorId,
projectId: document.projectId,
status: document.status, status: document.status,
issueDate: document.issueDate, issueDate: document.issueDate,
taxPercent: document.taxPercent, taxPercent: document.taxPercent,
freightAmount: document.freightAmount, freightAmount: document.freightAmount,
notes: document.notes, notes: document.notes,
revisionReason: "",
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({ lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
itemId: line.itemId, itemId: line.itemId,
description: line.description, description: line.description,
@@ -212,6 +226,15 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index)); setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index));
} }
const pendingLineRemoval =
pendingLineRemovalIndex != null
? {
index: pendingLineRemovalIndex,
line: form.lines[pendingLineRemovalIndex],
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
}
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -240,20 +263,24 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query); return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
}).length; }).length;
function closeEditor() {
navigate(mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`);
}
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Editor</p> <p className="section-kicker">PURCHASING EDITOR</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3> <h3 className="module-title">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
</div> </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"> <button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel Cancel
</Link> </button>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="space-y-3 surface-panel">
<div className="grid gap-3 xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-4">
<label className="block xl:col-span-2"> <label className="block xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Vendor</span> <span className="mb-2 block text-sm font-semibold text-text">Vendor</span>
@@ -329,10 +356,29 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
<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" /> <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> </label>
</div> </div>
<div className="rounded-[18px] 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">Linked Project</div>
<div className="mt-2 font-semibold text-text">
{mode === "edit"
? (form.projectId ? "Project context saved on this purchase order." : "No project linked.")
: (seededProjectId ? `${seededProjectNumber || "Project"}${seededProjectName ? ` - ${seededProjectName}` : ""}` : "Will auto-link from sales-order demand when possible.")}
</div>
</div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <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" /> <textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
{mode === "edit" ? (
<label className="block xl:max-w-xl">
<span className="mb-2 block text-sm font-semibold text-text">Revision Reason</span>
<input
value={form.revisionReason ?? ""}
onChange={(event) => updateField("revisionReason", event.target.value)}
placeholder="What changed in this revision?"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
) : null}
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span> <span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
@@ -344,20 +390,20 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
</label> </label>
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p> <p className="section-kicker">LINE ITEMS</p>
<h4 className="mt-2 text-lg font-bold text-text">Procurement lines</h4> <h4 className="text-lg font-bold text-text">PROCUREMENT LINES</h4>
</div> </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> <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> </div>
{form.lines.length === 0 ? ( {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-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No line items added yet.</div>
) : ( ) : (
<div className="mt-5 space-y-4"> <div className="mt-3 space-y-3">
{form.lines.map((line: PurchaseLineInput, index: number) => ( {form.lines.map((line: PurchaseLineInput, index: number) => (
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={index} className="rounded-[18px] 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]"> <div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
@@ -425,25 +471,45 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
<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" /> <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> </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"><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 className="flex items-end"><button type="button" onClick={() => setPendingLineRemovalIndex(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>
))} ))}
</div> </div>
)} )}
<div className="mt-5 grid gap-3 md:grid-cols-3 xl:grid-cols-4"> <div className="mt-4 grid gap-2 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">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">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">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 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>
<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"> <div className="mt-4 flex flex-col gap-2 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> <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"> <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"} {isSaving ? "Saving..." : mode === "create" ? "Create purchase order" : "Save changes"}
</button> </button>
</div> </div>
</section> </section>
<ConfirmActionDialog
open={pendingLineRemoval != null}
title="Remove purchase line"
description={
pendingLineRemoval
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the purchase order draft.`
: "Remove this purchase line."
}
impact="The line will be removed from the draft immediately and purchasing totals will recalculate."
recovery="Re-add the line before saving if the removal was accidental."
confirmLabel="Remove line"
onClose={() => setPendingLineRemovalIndex(null)}
onConfirm={() => {
if (pendingLineRemoval) {
removeLine(pendingLineRemoval.index);
}
setPendingLineRemovalIndex(null);
}}
/>
</form> </form>
); );
} }

View File

@@ -34,12 +34,11 @@ export function PurchaseListPage() {
}, [searchTerm, statusFilter, token]); }, [searchTerm, statusFilter, token]);
return ( return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing</p> <p className="section-kicker">PURCHASING</p>
<h3 className="mt-2 text-lg font-bold text-text">Purchase Orders</h3> <h3 className="module-title">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> </div>
{canManage ? ( {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"> <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">
@@ -47,7 +46,7 @@ export function PurchaseListPage() {
</Link> </Link>
) : null} ) : null}
</div> </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]"> <div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span> <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" /> <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" />
@@ -63,11 +62,11 @@ export function PurchaseListPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
{documents.length === 0 ? ( {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-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 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"> <div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
@@ -97,3 +96,4 @@ export function PurchaseListPage() {
</section> </section>
); );
} }

View File

@@ -22,11 +22,13 @@ export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
export const emptyPurchaseOrderInput: PurchaseOrderInput = { export const emptyPurchaseOrderInput: PurchaseOrderInput = {
vendorId: "", vendorId: "",
projectId: null,
status: "DRAFT", status: "DRAFT",
issueDate: new Date().toISOString(), issueDate: new Date().toISOString(),
taxPercent: 0, taxPercent: 0,
freightAmount: 0, freightAmount: 0,
notes: "", notes: "",
revisionReason: "",
lines: [], lines: [],
}; };

View File

@@ -6,13 +6,15 @@ import { Link, useNavigate, useParams } 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";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config"; import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
import { SalesStatusBadge } from "./SalesStatusBadge"; import { SalesStatusBadge } from "./SalesStatusBadge";
import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge"; import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge";
function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) { function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
return ( return (
<div className="rounded-3xl border border-line/70 bg-page/60 p-3" style={{ marginLeft: node.level * 12 }}> <div className="rounded-[18px] border border-line/70 bg-page/60 p-3" style={{ marginLeft: node.level * 12 }}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="font-semibold text-text"> <div className="font-semibold text-text">
@@ -45,6 +47,61 @@ function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
); );
} }
function formatCurrency(value: number) {
return `$${value.toFixed(2)}`;
}
function mapSalesDocumentForComparison(
document: Pick<
SalesDocumentDetailDto,
| "documentNumber"
| "customerName"
| "status"
| "issueDate"
| "expiresAt"
| "approvedAt"
| "approvedByName"
| "discountAmount"
| "discountPercent"
| "taxAmount"
| "taxPercent"
| "freightAmount"
| "subtotal"
| "total"
| "notes"
| "lines"
>
) {
return {
title: document.documentNumber,
subtitle: document.customerName,
status: document.status,
metaFields: [
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
{ label: "Expires", value: document.expiresAt ? new Date(document.expiresAt).toLocaleDateString() : "N/A" },
{ label: "Approval", value: document.approvedAt ? new Date(document.approvedAt).toLocaleDateString() : "Pending" },
{ label: "Approver", value: document.approvedByName ?? "No approver recorded" },
],
totalFields: [
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
{ label: "Discount", value: `${formatCurrency(document.discountAmount)} (${document.discountPercent.toFixed(2)}%)` },
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
{ label: "Freight", value: formatCurrency(document.freightAmount) },
{ label: "Total", value: formatCurrency(document.total) },
],
notes: document.notes,
lines: document.lines.map((line) => ({
key: line.id || `${line.itemId}-${line.position}`,
title: `${line.itemSku} | ${line.itemName}`,
subtitle: line.description,
quantity: `${line.quantity} ${line.unitOfMeasure}`,
unitLabel: line.unitOfMeasure,
amountLabel: formatCurrency(line.unitPrice),
totalLabel: formatCurrency(line.lineTotal),
})),
};
}
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const { token, user } = useAuth(); const { token, user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -59,6 +116,20 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const [isApproving, setIsApproving] = useState(false); const [isApproving, setIsApproving] = useState(false);
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]); const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null); const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "approve" | "convert";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus?: SalesDocumentStatus;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false; const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false;
@@ -89,7 +160,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
}, [canReadShipping, config.singularLabel, documentId, entity, token]); }, [canReadShipping, config.singularLabel, documentId, entity, token]);
if (!document) { if (!document) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
const activeDocument = document; const activeDocument = document;
@@ -102,6 +173,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
status: "DRAFT", status: "DRAFT",
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`, notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
}); });
if (activeDocument.linkedProjectId) {
params.set("projectId", activeDocument.linkedProjectId);
}
return `/manufacturing/work-orders/new?${params.toString()}`; return `/manufacturing/work-orders/new?${params.toString()}`;
} }
@@ -115,11 +189,20 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
if (vendorId) { if (vendorId) {
params.set("vendorId", vendorId); params.set("vendorId", vendorId);
} }
if (activeDocument.linkedProjectId) {
params.set("projectId", activeDocument.linkedProjectId);
}
if (activeDocument.linkedProjectNumber) {
params.set("projectNumber", activeDocument.linkedProjectNumber);
}
if (activeDocument.linkedProjectName) {
params.set("projectName", activeDocument.linkedProjectName);
}
return `/purchasing/orders/new?${params.toString()}`; return `/purchasing/orders/new?${params.toString()}`;
} }
async function handleStatusChange(nextStatus: SalesDocumentStatus) { async function applyStatusChange(nextStatus: SalesDocumentStatus) {
if (!token) { if (!token) {
return; return;
} }
@@ -133,7 +216,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
? await api.updateQuoteStatus(token, activeDocument.id, nextStatus) ? await api.updateQuoteStatus(token, activeDocument.id, nextStatus)
: await api.updateSalesOrderStatus(token, activeDocument.id, nextStatus); : await api.updateSalesOrderStatus(token, activeDocument.id, nextStatus);
setDocument(nextDocument); setDocument(nextDocument);
setStatus(`${config.singularLabel} status updated.`); setStatus(`${config.singularLabel} status updated. Review revisions and downstream workflows if the document moved into a terminal or customer-visible state.`);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to update ${config.singularLabel.toLowerCase()} status.`; const message = error instanceof ApiError ? error.message : `Unable to update ${config.singularLabel.toLowerCase()} status.`;
setStatus(message); setStatus(message);
@@ -142,7 +225,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
} }
} }
async function handleConvert() { async function applyConvert() {
if (!token || entity !== "quote") { if (!token || entity !== "quote") {
return; return;
} }
@@ -185,7 +268,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
} }
} }
async function handleApprove() { async function applyApprove() {
if (!token) { if (!token) {
return; return;
} }
@@ -197,7 +280,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const nextDocument = const nextDocument =
entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id); entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id);
setDocument(nextDocument); setDocument(nextDocument);
setStatus(`${config.singularLabel} approved.`); setStatus(`${config.singularLabel} approved. The approval stamp is now part of the document history and downstream teams can act on it immediately.`);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to approve ${config.singularLabel.toLowerCase()}.`; const message = error instanceof ApiError ? error.message : `Unable to approve ${config.singularLabel.toLowerCase()}.`;
setStatus(message); setStatus(message);
@@ -206,13 +289,57 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
} }
} }
function handleStatusChange(nextStatus: SalesDocumentStatus) {
const label = salesStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
setPendingConfirmation({
kind: "status",
title: `Set ${config.singularLabel.toLowerCase()} to ${label}`,
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
impact:
nextStatus === "CLOSED"
? "This closes the document operationally and can change customer-facing execution assumptions and downstream follow-up expectations."
: nextStatus === "APPROVED"
? "This marks the document ready for downstream action and becomes part of the approval history."
: "This changes the operational state used by downstream workflows and audit/revision history.",
recovery: "If this status is set in error, return the document to the correct state and verify the latest revision history.",
confirmLabel: `Set ${label}`,
confirmationLabel: nextStatus === "CLOSED" ? "Type document number to confirm:" : undefined,
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
nextStatus,
});
}
function handleApprove() {
setPendingConfirmation({
kind: "approve",
title: `Approve ${config.singularLabel.toLowerCase()}`,
description: `Approve ${activeDocument.documentNumber} for ${activeDocument.customerName}.`,
impact: "Approval records the approver and timestamp and signals that downstream execution can proceed.",
recovery: "If approval was granted by mistake, change the document status and review the revision trail for follow-up.",
confirmLabel: "Approve document",
});
}
function handleConvert() {
setPendingConfirmation({
kind: "convert",
title: "Convert quote to sales order",
description: `Create a sales order from quote ${activeDocument.documentNumber}.`,
impact: "This creates a new sales order record and can trigger planning, purchasing, manufacturing, and shipping follow-up work.",
recovery: "Review the new order immediately after creation. If conversion was premature, move the resulting order to the correct status and coordinate with downstream teams.",
confirmLabel: "Convert quote",
confirmationLabel: "Type quote number to confirm:",
confirmationValue: activeDocument.documentNumber,
});
}
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow}</p> <p className="section-kicker">{config.detailEyebrow.toUpperCase()}</p>
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3> <h3 className="module-title">{activeDocument.documentNumber}</h3>
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p> <p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<SalesStatusBadge status={activeDocument.status} /> <SalesStatusBadge status={activeDocument.status} />
@@ -269,11 +396,10 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p> <p className="section-kicker">QUICK ACTIONS</p>
<p className="mt-2 text-sm text-muted">Update document status without opening the full editor.</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{salesStatusOptions.map((option) => ( {salesStatusOptions.map((option) => (
@@ -291,60 +417,59 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div> </div>
</section> </section>
) : null} ) : null}
<section className="grid gap-3 xl:grid-cols-4"> <section className="grid gap-2 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p> <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> <div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Expires</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Expires</p>
<div className="mt-2 text-base font-bold text-text">{activeDocument.expiresAt ? new Date(activeDocument.expiresAt).toLocaleDateString() : "N/A"}</div> <div className="mt-2 text-base font-bold text-text">{activeDocument.expiresAt ? new Date(activeDocument.expiresAt).toLocaleDateString() : "N/A"}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p> <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> <div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Approval</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Approval</p>
<div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div> <div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div>
<div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div> <div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
</article> </article>
</section> </section>
<section className="grid gap-3 xl:grid-cols-4"> <section className="grid gap-2 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Discount</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Discount</p>
<div className="mt-2 text-base font-bold text-text">-${activeDocument.discountAmount.toFixed(2)}</div> <div className="mt-2 text-base font-bold text-text">-${activeDocument.discountAmount.toFixed(2)}</div>
<div className="mt-1 text-xs text-muted">{activeDocument.discountPercent.toFixed(2)}%</div> <div className="mt-1 text-xs text-muted">{activeDocument.discountPercent.toFixed(2)}%</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p> <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-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> <div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p> <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> <div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p> <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> <div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div>
</article> </article>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Revision History</p> <p className="section-kicker">REVISION HISTORY</p>
<p className="mt-2 text-sm text-muted">Automatic snapshots are recorded when the document changes status, content, or approval state.</p>
</div> </div>
</div> </div>
{activeDocument.revisions.length === 0 ? ( {activeDocument.revisions.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No revisions have been recorded yet. No revisions recorded yet.
</div> </div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{activeDocument.revisions.map((revision) => ( {activeDocument.revisions.map((revision) => (
<article key={revision.id} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div> <div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
@@ -360,9 +485,40 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div> </div>
)} )}
</section> </section>
{activeDocument.revisions.length > 0 ? (
<DocumentRevisionComparison
title="Revision Comparison"
description="Compare a prior revision against the current document or another revision to see commercial and line-level changes."
currentLabel="Current document"
currentDocument={mapSalesDocumentForComparison(activeDocument)}
revisions={activeDocument.revisions.map((revision) => ({
id: revision.id,
label: `Rev ${revision.revisionNumber}`,
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
}))}
getRevisionDocument={(revisionId) => {
if (revisionId === "current") {
return mapSalesDocumentForComparison(activeDocument);
}
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
if (!revision) {
return mapSalesDocumentForComparison(activeDocument);
}
return mapSalesDocumentForComparison({
...revision.snapshot,
lines: revision.snapshot.lines.map((line) => ({
id: `${line.itemId}-${line.position}`,
...line,
})),
});
}}
/>
) : null}
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]"> <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"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer</p> <p className="section-kicker">CUSTOMER</p>
<dl className="mt-5 grid gap-3"> <dl className="mt-5 grid gap-3">
<div> <div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt>
@@ -374,19 +530,30 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div> </div>
</dl> </dl>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p> <p className="section-kicker">PROJECT LINK</p>
{activeDocument.linkedProjectId ? (
<div className="mt-3 space-y-2">
<Link to={`/projects/${activeDocument.linkedProjectId}`} className="inline-flex items-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text hover:bg-page/70">
{activeDocument.linkedProjectNumber} / {activeDocument.linkedProjectName}
</Link>
<p className="text-sm text-muted">Downstream WO and PO launches carry this project context.</p>
</div>
) : (
<p className="mt-3 text-sm text-muted">No linked project.</p>
)}
<p className="mt-4 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> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
</article> </article>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p> <p className="section-kicker">LINE ITEMS</p>
{activeDocument.lines.length === 0 ? ( {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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No line items have been added yet. No line items added yet.
</div> </div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-3 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
@@ -418,37 +585,33 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
)} )}
</section> </section>
{entity === "order" && planning ? ( {entity === "order" && planning ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Planning</p> <p className="section-kicker">DEMAND PLANNING</p>
<h3 className="mt-2 text-lg font-bold text-text">Net build and buy requirements</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Sales-order demand is netted against available stock, active reservations, open work orders, and open purchase orders before new build or buy quantities are recommended.
</p>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div> <div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
<div>Status {planning.status}</div> <div>Status {planning.status}</div>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-3 xl:grid-cols-4"> <div className="mt-4 grid gap-2 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3"> <article className="surface-panel-tight bg-page/70 shadow-none">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div> <div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div> <div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3"> <article className="surface-panel-tight bg-page/70 shadow-none">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div> <div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div> <div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3"> <article className="surface-panel-tight bg-page/70 shadow-none">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div> <div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div> <div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3"> <article className="surface-panel-tight bg-page/70 shadow-none">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div> <div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div> <div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div>
@@ -519,9 +682,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</Link> </Link>
</div> </div>
) : null} ) : null}
<div className="mt-5 space-y-3"> <div className="mt-4 space-y-2">
{planning.lines.map((line) => ( {planning.lines.map((line) => (
<div key={line.lineId} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={line.lineId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="mb-3"> <div className="mb-3">
<div className="font-semibold text-text"> <div className="font-semibold text-text">
{line.itemSku} <span className="text-muted">{line.itemName}</span> {line.itemSku} <span className="text-muted">{line.itemName}</span>
@@ -537,11 +700,10 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</section> </section>
) : null} ) : null}
{entity === "order" && canReadShipping ? ( {entity === "order" && canReadShipping ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p> <p className="section-kicker">SHIPPING</p>
<p className="mt-2 text-sm text-muted">Shipment records currently tied to this sales order.</p>
</div> </div>
{canManageShipping ? ( {canManageShipping ? (
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
@@ -550,13 +712,13 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
) : null} ) : null}
</div> </div>
{shipments.length === 0 ? ( {shipments.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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No shipments have been created for this sales order yet. No shipments created yet.
</div> </div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{shipments.map((shipment) => ( {shipments.map((shipment) => (
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"> <Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-[18px] 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 className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">{shipment.shipmentNumber}</div> <div className="font-semibold text-text">{shipment.shipmentNumber}</div>
@@ -570,6 +732,49 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
)} )}
</section> </section>
) : null} ) : null}
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm sales action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "approve" && isApproving) ||
(pendingConfirmation?.kind === "convert" && isConverting)
}
onClose={() => {
if (!isUpdatingStatus && !isApproving && !isConverting) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
await applyStatusChange(pendingConfirmation.nextStatus);
setPendingConfirmation(null);
return;
}
if (pendingConfirmation.kind === "approve") {
await applyApprove();
setPendingConfirmation(null);
return;
}
if (pendingConfirmation.kind === "convert") {
await applyConvert();
setPendingConfirmation(null);
}
}}
/>
</section> </section>
); );
} }

View File

@@ -1,8 +1,9 @@
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js"; import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/types.js"; import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { inventoryUnitOptions } from "../inventory/config"; import { inventoryUnitOptions } from "../inventory/config";
@@ -23,6 +24,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]); const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null); const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
const subtotal = form.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0); const subtotal = form.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const discountAmount = subtotal * (form.discountPercent / 100); const discountAmount = subtotal * (form.discountPercent / 100);
@@ -129,6 +131,15 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index)); setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index));
} }
const pendingLineRemoval =
pendingLineRemovalIndex != null
? {
index: pendingLineRemovalIndex,
line: form.lines[pendingLineRemovalIndex],
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
}
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -156,20 +167,24 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
} }
} }
function closeEditor() {
navigate(mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`);
}
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow} Editor</p> <p className="section-kicker">{`${config.detailEyebrow} EDITOR`.toUpperCase()}</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3> <h3 className="module-title">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
</div> </div>
<Link to={mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel Cancel
</Link> </button>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="space-y-3 surface-panel">
<div className="grid gap-3 xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-4">
<label className="block xl:col-span-2"> <label className="block xl:col-span-2">
<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>
@@ -288,7 +303,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
value={form.notes} value={form.notes}
onChange={(event) => updateField("notes", event.target.value)} onChange={(event) => updateField("notes", event.target.value)}
rows={3} 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" className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/> />
</label> </label>
{mode === "edit" ? ( {mode === "edit" ? (
@@ -340,24 +355,24 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
</label> </label>
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p> <p className="section-kicker">LINE ITEMS</p>
<h4 className="mt-2 text-lg font-bold text-text">Commercial lines</h4> <h4 className="text-lg font-bold text-text">COMMERCIAL LINES</h4>
</div> </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"> <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 Add line
</button> </button>
</div> </div>
{form.lines.length === 0 ? ( {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"> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No line items added yet. No line items added yet.
</div> </div>
) : ( ) : (
<div className="mt-5 space-y-4"> <div className="mt-3 space-y-3">
{form.lines.map((line: SalesLineInput, index: number) => ( {form.lines.map((line: SalesLineInput, index: number) => (
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={index} className="rounded-[18px] 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]"> <div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
@@ -431,7 +446,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
</div> </div>
</div> </div>
<div className="flex items-end"> <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"> <button type="button" onClick={() => setPendingLineRemovalIndex(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 Remove
</button> </button>
</div> </div>
@@ -440,7 +455,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
))} ))}
</div> </div>
)} )}
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"> <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="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 className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div>
@@ -458,13 +473,34 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
<div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div> <div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div>
</div> </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"> <div className="mt-4 flex flex-col gap-2 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> <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"> <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 ${config.singularLabel.toLowerCase()}` : "Save changes"} {isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel.toLowerCase()}` : "Save changes"}
</button> </button>
</div> </div>
</section> </section>
<ConfirmActionDialog
open={pendingLineRemoval != null}
title={`Remove ${config.singularLabel.toLowerCase()} line`}
description={
pendingLineRemoval
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the ${config.singularLabel.toLowerCase()}.`
: "Remove this line."
}
impact="The line will be dropped from the document draft immediately and totals will recalculate."
recovery="Add the line back manually before saving if this removal was a mistake."
confirmLabel="Remove line"
isConfirming={false}
onClose={() => setPendingLineRemovalIndex(null)}
onConfirm={() => {
if (pendingLineRemoval) {
removeLine(pendingLineRemoval.index);
}
setPendingLineRemovalIndex(null);
}}
/>
</form> </form>
); );
} }

View File

@@ -40,14 +40,11 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]); }, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
return ( return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.listEyebrow}</p> <p className="section-kicker">{config.listEyebrow.toUpperCase()}</p>
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3> <h3 className="module-title">{config.collectionLabel}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Customer-facing commercial documents for pricing, commitment, and downstream fulfillment planning.
</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link to={`${config.routeBase}/new`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white"> <Link to={`${config.routeBase}/new`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
@@ -55,7 +52,7 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
</Link> </Link>
) : null} ) : null}
</div> </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]"> <div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input <input
@@ -80,13 +77,13 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
</select> </select>
</label> </label>
</div> </div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
{documents.length === 0 ? ( {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"> <div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
No {config.collectionLabel.toLowerCase()} have been added yet. No {config.collectionLabel.toLowerCase()} have been added yet.
</div> </div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
@@ -127,3 +124,4 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
</section> </section>
); );
} }

View File

@@ -1,4 +1,4 @@
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto } from "@mrp/shared"; import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto } from "@mrp/shared";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -21,8 +21,28 @@ export function AdminDiagnosticsPage() {
const { token } = useAuth(); const { token } = useAuth();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null); const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null); const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
const [supportLogs, setSupportLogs] = useState<SupportLogEntryDto[]>([]); const [supportLogData, setSupportLogData] = useState<SupportLogListDto | null>(null);
const [status, setStatus] = useState("Loading diagnostics..."); const [status, setStatus] = useState("Loading diagnostics...");
const [supportLogLevel, setSupportLogLevel] = useState<"ALL" | SupportLogEntryDto["level"]>("ALL");
const [supportLogSource, setSupportLogSource] = useState("ALL");
const [supportLogQuery, setSupportLogQuery] = useState("");
const [supportLogWindowDays, setSupportLogWindowDays] = useState<"ALL" | "1" | "7" | "14">("ALL");
function buildSupportLogFilters(): SupportLogFiltersDto {
const now = new Date();
const start =
supportLogWindowDays === "ALL"
? undefined
: new Date(now.getTime() - Number.parseInt(supportLogWindowDays, 10) * 24 * 60 * 60 * 1000).toISOString();
return {
level: supportLogLevel === "ALL" ? undefined : supportLogLevel,
source: supportLogSource === "ALL" ? undefined : supportLogSource,
query: supportLogQuery.trim() || undefined,
start,
limit: 100,
};
}
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -31,14 +51,14 @@ export function AdminDiagnosticsPage() {
let active = true; let active = true;
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token)]) Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token, buildSupportLogFilters())])
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => { .then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
if (!active) { if (!active) {
return; return;
} }
setDiagnostics(nextDiagnostics); setDiagnostics(nextDiagnostics);
setBackupGuidance(nextBackupGuidance); setBackupGuidance(nextBackupGuidance);
setSupportLogs(nextSupportLogs); setSupportLogData(nextSupportLogs);
setStatus("Diagnostics loaded."); setStatus("Diagnostics loaded.");
}) })
.catch((error: Error) => { .catch((error: Error) => {
@@ -51,10 +71,10 @@ export function AdminDiagnosticsPage() {
return () => { return () => {
active = false; active = false;
}; };
}, [token]); }, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
if (!diagnostics || !backupGuidance) { if (!diagnostics || !backupGuidance) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="surface-panel text-sm text-muted">{status}</div>;
} }
async function handleExportSupportSnapshot() { async function handleExportSupportSnapshot() {
@@ -62,7 +82,7 @@ export function AdminDiagnosticsPage() {
return; return;
} }
const snapshot = await api.getSupportSnapshot(token); const snapshot = await api.getSupportSnapshotWithFilters(token, buildSupportLogFilters());
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
const objectUrl = window.URL.createObjectURL(blob); const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
@@ -78,7 +98,7 @@ export function AdminDiagnosticsPage() {
return; return;
} }
const logs = await api.getSupportLogs(token); const logs = await api.getSupportLogs(token, buildSupportLogFilters());
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" });
const objectUrl = window.URL.createObjectURL(blob); const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
@@ -86,16 +106,22 @@ export function AdminDiagnosticsPage() {
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
link.click(); link.click();
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000); window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
setSupportLogs(logs); setSupportLogData(logs);
setStatus("Support logs exported."); setStatus("Support logs exported.");
} }
const supportLogs = supportLogData?.entries ?? [];
const supportLogSummary = supportLogData?.summary;
const supportLogSources = supportLogData?.availableSources ?? [];
const summaryCards = [ const summaryCards = [
["Server time", formatDateTime(diagnostics.serverTime)], ["Server time", formatDateTime(diagnostics.serverTime)],
["Node runtime", diagnostics.nodeVersion], ["Node runtime", diagnostics.nodeVersion],
["Audit events", diagnostics.auditEventCount.toString()], ["Audit events", diagnostics.auditEventCount.toString()],
["Support logs", diagnostics.supportLogCount.toString()], ["Support logs", diagnostics.supportLogCount.toString()],
["Retention", `${supportLogSummary?.retentionDays ?? 0} days`],
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`], ["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
["Sessions to review", diagnostics.reviewSessionCount.toString()],
["Sales docs", diagnostics.salesDocumentCount.toString()], ["Sales docs", diagnostics.salesDocumentCount.toString()],
["Work orders", diagnostics.workOrderCount.toString()], ["Work orders", diagnostics.workOrderCount.toString()],
["Projects", diagnostics.projectCount.toString()], ["Projects", diagnostics.projectCount.toString()],
@@ -108,6 +134,7 @@ export function AdminDiagnosticsPage() {
["Uploads directory", diagnostics.uploadsDir], ["Uploads directory", diagnostics.uploadsDir],
["Client origin", diagnostics.clientOrigin], ["Client origin", diagnostics.clientOrigin],
["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"], ["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"],
["Active sessions", diagnostics.activeSessionCount.toString()],
["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`], ["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`],
["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`], ["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`],
["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`], ["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`],
@@ -129,15 +156,12 @@ export function AdminDiagnosticsPage() {
]; ];
return ( return (
<div className="space-y-6"> <div className="page-stack">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin Diagnostics</p> <p className="section-kicker">ADMIN DIAGNOSTICS</p>
<h3 className="mt-2 text-lg font-bold text-text">Operational runtime and audit visibility</h3> <h3 className="module-title">RUNTIME AUDIT SUPPORT</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
This view surfaces environment footprint, record counts, and recent change activity so admin review does not require direct database access.
</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
@@ -162,35 +186,31 @@ export function AdminDiagnosticsPage() {
</Link> </Link>
</div> </div>
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(([label, value]) => ( {summaryCards.map(([label, value]) => (
<div key={label} className="rounded-3xl border border-line/70 bg-page/70 p-4"> <div key={label} className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
<p className="mt-3 text-lg font-bold text-text">{value}</p> <p className="mt-2 text-lg font-bold text-text">{value}</p>
</div> </div>
))} ))}
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Backup And Restore</p> <p className="section-kicker">BACKUP AND RESTORE</p>
<h3 className="mt-2 text-lg font-bold text-text">Operational backup workflow</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Use these paths and steps as the support baseline for manual backup and restore procedures.
</p>
</div> </div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted"> <div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
<div>Data: {backupGuidance.dataPath}</div> <div>Data: {backupGuidance.dataPath}</div>
<div>DB: {backupGuidance.databasePath}</div> <div>DB: {backupGuidance.databasePath}</div>
<div>Uploads: {backupGuidance.uploadsPath}</div> <div>Uploads: {backupGuidance.uploadsPath}</div>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-4 xl:grid-cols-2"> <div className="mt-3 grid gap-3 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4"> <div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">Backup checklist</p> <p className="text-sm font-semibold text-text">Backup checklist</p>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-2">
{backupGuidance.backupSteps.map((step) => ( {backupGuidance.backupSteps.map((step) => (
<div key={step.id}> <div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p> <p className="text-sm font-semibold text-text">{step.label}</p>
@@ -199,9 +219,9 @@ export function AdminDiagnosticsPage() {
))} ))}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-line/70 bg-page/70 p-4"> <div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">Restore checklist</p> <p className="text-sm font-semibold text-text">Restore checklist</p>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-2">
{backupGuidance.restoreSteps.map((step) => ( {backupGuidance.restoreSteps.map((step) => (
<div key={step.id}> <div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p> <p className="text-sm font-semibold text-text">{step.label}</p>
@@ -211,10 +231,10 @@ export function AdminDiagnosticsPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-4 xl:grid-cols-2"> <div className="mt-3 grid gap-3 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4"> <div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">Backup verification checklist</p> <p className="text-sm font-semibold text-text">Backup verification checklist</p>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-2">
{backupGuidance.verificationChecklist.map((item) => ( {backupGuidance.verificationChecklist.map((item) => (
<div key={item.id}> <div key={item.id}>
<p className="text-sm font-semibold text-text">{item.label}</p> <p className="text-sm font-semibold text-text">{item.label}</p>
@@ -224,9 +244,9 @@ export function AdminDiagnosticsPage() {
))} ))}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-line/70 bg-page/70 p-4"> <div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">Restore drill runbook</p> <p className="text-sm font-semibold text-text">Restore drill runbook</p>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-2">
{backupGuidance.restoreDrillSteps.map((step) => ( {backupGuidance.restoreDrillSteps.map((step) => (
<div key={step.id}> <div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p> <p className="text-sm font-semibold text-text">{step.label}</p>
@@ -239,17 +259,16 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Startup Validation</p> <p className="section-kicker">STARTUP VALIDATION</p>
<h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
</div> </div>
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}> <span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
{diagnostics.startup.status} {diagnostics.startup.status}
</span> </span>
</div> </div>
<div className="mt-5 grid gap-3 xl:grid-cols-2"> <div className="mt-3 grid gap-3 xl:grid-cols-2">
{diagnostics.startup.checks.map((check) => ( {diagnostics.startup.checks.map((check) => (
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3"> <div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -260,7 +279,7 @@ export function AdminDiagnosticsPage() {
</div> </div>
))} ))}
</div> </div>
<div className="mt-5 grid gap-3 lg:grid-cols-3"> <div className="mt-3 grid gap-3 lg:grid-cols-3">
{startupSummaryCards.map(([label, value]) => ( {startupSummaryCards.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3"> <div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
@@ -270,9 +289,9 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p> <p className="section-kicker">SYSTEM FOOTPRINT</p>
<div className="mt-5 grid gap-3 xl:grid-cols-2"> <div className="mt-3 grid gap-3 xl:grid-cols-2">
{footprintCards.map(([label, value]) => ( {footprintCards.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3"> <div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
@@ -282,15 +301,59 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel backdrop-blur">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Support Logs</p> <p className="section-kicker">SUPPORT LOGS</p>
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
</div> </div>
<p className="text-sm text-muted">{supportLogs.length} entries loaded</p> <p className="text-sm text-muted">
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
</p>
</div> </div>
<div className="mt-5 overflow-x-auto"> <div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={supportLogQuery}
onChange={(event) => setSupportLogQuery(event.target.value)}
placeholder="Message, source, context"
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level</span>
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
<option value="ALL">All levels</option>
<option value="ERROR">Error</option>
<option value="WARN">Warn</option>
<option value="INFO">Info</option>
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Source</span>
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
<option value="ALL">All sources</option>
{supportLogSources.map((source) => (
<option key={source} value={source}>{source}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Window</span>
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
<option value="ALL">All retained</option>
<option value="1">Last 24 hours</option>
<option value="7">Last 7 days</option>
<option value="14">Last 14 days</option>
</select>
</label>
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
</div>
</div>
<div className="mt-3 overflow-x-auto">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead> <thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted"> <tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
@@ -320,20 +383,26 @@ export function AdminDiagnosticsPage() {
</tr> </tr>
); );
})} })}
{supportLogs.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-muted">
No support logs matched the current filters.
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel backdrop-blur">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Audit Trail</p> <p className="section-kicker">RECENT AUDIT TRAIL</p>
<h3 className="mt-2 text-lg font-bold text-text">Latest cross-module write activity</h3>
</div> </div>
<p className="text-sm text-muted">{status}</p> <p className="text-sm text-muted">{status}</p>
</div> </div>
<div className="mt-5 overflow-x-auto"> <div className="mt-3 overflow-x-auto">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead> <thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted"> <tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
@@ -375,3 +444,4 @@ export function AdminDiagnosticsPage() {
</div> </div>
); );
} }

View File

@@ -93,7 +93,7 @@ export function CompanySettingsPage() {
}, [logoUrl]); }, [logoUrl]);
if (!form || !token) { if (!form || !token) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="surface-panel text-sm text-muted">{status}</div>;
} }
async function handleSave(event: React.FormEvent<HTMLFormElement>) { async function handleSave(event: React.FormEvent<HTMLFormElement>) {
@@ -145,14 +145,13 @@ export function CompanySettingsPage() {
} }
return ( return (
<form className="space-y-6" onSubmit={handleSave}> <form className="page-stack" onSubmit={handleSave}>
{user?.permissions.includes("admin.manage") ? ( {user?.permissions.includes("admin.manage") ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p> <p className="section-kicker">ADMIN</p>
<h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3> <h3 className="module-title">ADMIN SURFACES</h3>
<p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"> <Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
@@ -165,14 +164,13 @@ export function CompanySettingsPage() {
</div> </div>
</section> </section>
) : null} ) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p> <p className="section-kicker">COMPANY PROFILE</p>
<h3 className="mt-2 text-lg font-bold text-text">Branding and legal identity</h3> <h3 className="module-title">BRANDING AND LEGAL IDENTITY</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Every internal document and PDF template will inherit its company identity from this profile.</p>
</div> </div>
<div className="rounded-3xl border border-dashed border-line/70 bg-page/80 p-4"> <div className="rounded-[18px] border border-dashed border-line/70 bg-page/80 px-3 py-3">
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>} {logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand"> <label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
Upload logo Upload logo
@@ -180,7 +178,7 @@ export function CompanySettingsPage() {
</label> </label>
</div> </div>
</div> </div>
<div className="mt-6 grid gap-4 xl:grid-cols-2 2xl:grid-cols-3"> <div className="mt-3 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
{[ {[
["companyName", "Company name"], ["companyName", "Company name"],
["legalName", "Legal name"], ["legalName", "Legal name"],
@@ -196,37 +194,37 @@ export function CompanySettingsPage() {
["country", "Country"], ["country", "Country"],
].map(([key, label]) => ( ].map(([key, label]) => (
<label key={key} className="block"> <label key={key} className="block">
<span className="mb-2 block text-sm font-semibold text-text">{label}</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">{label}</span>
<input <input
value={String(form[key as keyof CompanyProfileInput])} value={String(form[key as keyof CompanyProfileInput])}
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)} onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" 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>
))} ))}
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p> <p className="section-kicker">THEME</p>
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-4"> <div className="mt-3 grid gap-3 md:grid-cols-2 2xl:grid-cols-4">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Primary color</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Primary color</span>
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" /> <input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Accent color</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Accent color</span>
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" /> <input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Surface color</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Surface color</span>
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" /> <input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Font family</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Font family</span>
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: 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" /> <input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: 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" />
</label> </label>
</div> </div>
<div className="mt-5 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"> <div className="mt-3 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span> <span className="min-w-0 text-sm text-muted">{status}</span>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
@@ -245,3 +243,4 @@ export function CompanySettingsPage() {
</form> </form>
); );
} }

View File

@@ -1,9 +1,17 @@
import type { AdminPermissionOptionDto, AdminRoleDto, AdminRoleInput, AdminUserDto, AdminUserInput } from "@mrp/shared"; import type {
AdminAuthSessionDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
} from "@mrp/shared";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
const emptyUserForm: AdminUserInput = { const emptyUserForm: AdminUserInput = {
email: "", email: "",
@@ -21,15 +29,36 @@ const emptyRoleForm: AdminRoleInput = {
}; };
export function UserManagementPage() { export function UserManagementPage() {
const { token } = useAuth(); const { token, user: authUser, logout } = useAuth();
const [users, setUsers] = useState<AdminUserDto[]>([]); const [users, setUsers] = useState<AdminUserDto[]>([]);
const [roles, setRoles] = useState<AdminRoleDto[]>([]); const [roles, setRoles] = useState<AdminRoleDto[]>([]);
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]); const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
const [sessions, setSessions] = useState<AdminAuthSessionDto[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>("new"); const [selectedUserId, setSelectedUserId] = useState<string>("new");
const [selectedRoleId, setSelectedRoleId] = useState<string>("new"); const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
const [sessionStatusFilter, setSessionStatusFilter] = useState<"ALL" | AdminAuthSessionDto["status"]>("ALL");
const [sessionReviewFilter, setSessionReviewFilter] = useState<"ALL" | AdminAuthSessionDto["reviewState"]>("ALL");
const [sessionQuery, setSessionQuery] = useState("");
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm); const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm); const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
const [status, setStatus] = useState("Loading admin access controls..."); const [status, setStatus] = useState("Loading admin access controls...");
const [isConfirmingAction, setIsConfirmingAction] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "deactivate-user" | "revoke-session";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
userId?: string;
sessionId?: string;
}
| null
>(null);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -38,14 +67,15 @@ export function UserManagementPage() {
let active = true; let active = true;
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token)]) Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token), api.getAdminSessions(token)])
.then(([nextUsers, nextRoles, nextPermissions]) => { .then(([nextUsers, nextRoles, nextPermissions, nextSessions]) => {
if (!active) { if (!active) {
return; return;
} }
setUsers(nextUsers); setUsers(nextUsers);
setRoles(nextRoles); setRoles(nextRoles);
setPermissions(nextPermissions); setPermissions(nextPermissions);
setSessions(nextSessions);
setStatus("User management loaded."); setStatus("User management loaded.");
}) })
.catch((error: Error) => { .catch((error: Error) => {
@@ -108,43 +138,78 @@ export function UserManagementPage() {
const authToken = token; const authToken = token;
async function refreshData(nextStatus: string) { async function refreshData(nextStatus: string) {
const [nextUsers, nextRoles, nextPermissions] = await Promise.all([ const [nextUsers, nextRoles, nextPermissions, nextSessions] = await Promise.all([
api.getAdminUsers(authToken), api.getAdminUsers(authToken),
api.getAdminRoles(authToken), api.getAdminRoles(authToken),
api.getAdminPermissions(authToken), api.getAdminPermissions(authToken),
api.getAdminSessions(authToken),
]); ]);
setUsers(nextUsers); setUsers(nextUsers);
setRoles(nextRoles); setRoles(nextRoles);
setPermissions(nextPermissions); setPermissions(nextPermissions);
setSessions(nextSessions);
setStatus(nextStatus); setStatus(nextStatus);
} }
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) { async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const selectedUser = users.find((entry) => entry.id === selectedUserId);
if (selectedUser && selectedUser.isActive && !userForm.isActive) {
setPendingConfirmation({
kind: "deactivate-user",
title: `Deactivate ${selectedUser.firstName} ${selectedUser.lastName}`,
description: `Disable sign-in for ${selectedUser.email}. Existing active sessions will remain revoked only if you separately revoke them below.`,
impact: "The user will be blocked from new sign-ins as soon as this save completes.",
recovery: "Re-enable the account later if the change was made in error, and revoke live sessions separately if immediate cut-off is required.",
confirmLabel: "Deactivate user",
confirmationLabel: "Type user email to confirm:",
confirmationValue: selectedUser.email,
userId: selectedUser.id,
});
return;
}
try {
await saveUser();
} catch (error: unknown) {
setStatus(error instanceof Error ? error.message : "Unable to save user.");
}
}
async function saveUser() {
const normalizedUserForm: AdminUserInput = {
...userForm,
password: userForm.password && userForm.password.trim().length > 0 ? userForm.password : null,
};
if (selectedUserId === "new") { if (selectedUserId === "new") {
const createdUser = await api.createAdminUser(authToken, userForm); const createdUser = await api.createAdminUser(authToken, normalizedUserForm);
await refreshData(`Created user ${createdUser.email}.`); await refreshData(`Created user ${createdUser.email}.`);
setSelectedUserId(createdUser.id); setSelectedUserId(createdUser.id);
return; return;
} }
const updatedUser = await api.updateAdminUser(authToken, selectedUserId, userForm); const updatedUser = await api.updateAdminUser(authToken, selectedUserId, normalizedUserForm);
await refreshData(`Updated user ${updatedUser.email}.`); await refreshData(`Updated user ${updatedUser.email}.`);
setSelectedUserId(updatedUser.id); setSelectedUserId(updatedUser.id);
} }
async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) { async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (selectedRoleId === "new") { try {
const createdRole = await api.createAdminRole(authToken, roleForm); if (selectedRoleId === "new") {
await refreshData(`Created role ${createdRole.name}.`); const createdRole = await api.createAdminRole(authToken, roleForm);
setSelectedRoleId(createdRole.id); await refreshData(`Created role ${createdRole.name}.`);
return; setSelectedRoleId(createdRole.id);
} return;
}
const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm); const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm);
await refreshData(`Updated role ${updatedRole.name}.`); await refreshData(`Updated role ${updatedRole.name}.`);
setSelectedRoleId(updatedRole.id); setSelectedRoleId(updatedRole.id);
} catch (error: unknown) {
setStatus(error instanceof Error ? error.message : "Unable to save role.");
}
} }
function toggleUserRole(roleId: string) { function toggleUserRole(roleId: string) {
@@ -165,16 +230,54 @@ export function UserManagementPage() {
})); }));
} }
async function handleSessionRevoke(sessionId: string, isCurrentSession: boolean) {
await api.revokeAdminSession(authToken, sessionId);
if (isCurrentSession) {
await logout();
return;
}
await refreshData("Revoked session. The user must sign in again to restore access unless their account is inactive.");
}
const normalizedSessionQuery = sessionQuery.trim().toLowerCase();
const filteredSessions = sessions.filter((session) => {
if (sessionUserFilter !== "all" && session.userId !== sessionUserFilter) {
return false;
}
if (sessionStatusFilter !== "ALL" && session.status !== sessionStatusFilter) {
return false;
}
if (sessionReviewFilter !== "ALL" && session.reviewState !== sessionReviewFilter) {
return false;
}
if (!normalizedSessionQuery) {
return true;
}
return (
session.userName.toLowerCase().includes(normalizedSessionQuery) ||
session.userEmail.toLowerCase().includes(normalizedSessionQuery) ||
(session.ipAddress ?? "").toLowerCase().includes(normalizedSessionQuery) ||
(session.userAgent ?? "").toLowerCase().includes(normalizedSessionQuery) ||
session.reviewReasons.some((reason) => reason.toLowerCase().includes(normalizedSessionQuery))
);
});
const activeSessionCount = sessions.filter((session) => session.status === "ACTIVE").length;
const revokedSessionCount = sessions.filter((session) => session.status === "REVOKED").length;
const expiredSessionCount = sessions.filter((session) => session.status === "EXPIRED").length;
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
return ( return (
<div className="space-y-6"> <div className="page-stack">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5"> <section className="surface-panel backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">User Management</p> <p className="section-kicker">ADMIN</p>
<h3 className="mt-2 text-lg font-bold text-text">Accounts, roles, and permission assignment</h3> <h3 className="module-title">USERS ROLES SESSIONS</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Manage user accounts and the role-permission model from one admin surface so onboarding and access control stay tied together.
</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"> <Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
@@ -187,12 +290,11 @@ export function UserManagementPage() {
</div> </div>
</section> </section>
<section className="grid gap-6 xl:grid-cols-2"> <section className="grid gap-3 xl:grid-cols-2">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleUserSave}> <form className="surface-panel backdrop-blur" onSubmit={handleUserSave}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Users</p> <p className="section-kicker">USERS</p>
<h3 className="mt-2 text-lg font-bold text-text">Account generation and role assignment</h3>
</div> </div>
<select <select
value={selectedUserId} value={selectedUserId}
@@ -208,9 +310,9 @@ export function UserManagementPage() {
</select> </select>
</div> </div>
<div className="mt-5 grid gap-4 md:grid-cols-2"> <div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Email</span>
<input <input
value={userForm.email} value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))} onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
@@ -218,7 +320,7 @@ export function UserManagementPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Password</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Password</span>
<input <input
type="password" type="password"
value={userForm.password ?? ""} value={userForm.password ?? ""}
@@ -228,7 +330,7 @@ export function UserManagementPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">First name</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">First name</span>
<input <input
value={userForm.firstName} value={userForm.firstName}
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))} onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
@@ -236,7 +338,7 @@ export function UserManagementPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Last name</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Last name</span>
<input <input
value={userForm.lastName} value={userForm.lastName}
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))} onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
@@ -245,7 +347,7 @@ export function UserManagementPage() {
</label> </label>
</div> </div>
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text"> <label className="mt-3 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
<input <input
type="checkbox" type="checkbox"
checked={userForm.isActive} checked={userForm.isActive}
@@ -254,11 +356,11 @@ export function UserManagementPage() {
User can sign in User can sign in
</label> </label>
<div className="mt-5"> <div className="mt-3">
<p className="text-sm font-semibold text-text">Assigned roles</p> <p className="section-kicker">ASSIGNED ROLES</p>
<div className="mt-3 grid gap-3"> <div className="mt-3 grid gap-2">
{roles.map((role) => ( {roles.map((role) => (
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text"> <label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
<input <input
type="checkbox" type="checkbox"
checked={userForm.roleIds.includes(role.id)} checked={userForm.roleIds.includes(role.id)}
@@ -273,7 +375,7 @@ export function UserManagementPage() {
</div> </div>
</div> </div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3"> <div className="mt-3 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{status}</span> <span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white"> <button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedUserId === "new" ? "Create user" : "Save user"} {selectedUserId === "new" ? "Create user" : "Save user"}
@@ -281,11 +383,10 @@ export function UserManagementPage() {
</div> </div>
</form> </form>
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleRoleSave}> <form className="surface-panel backdrop-blur" onSubmit={handleRoleSave}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roles</p> <p className="section-kicker">ROLES</p>
<h3 className="mt-2 text-lg font-bold text-text">Permission assignment administration</h3>
</div> </div>
<select <select
value={selectedRoleId} value={selectedRoleId}
@@ -301,9 +402,9 @@ export function UserManagementPage() {
</select> </select>
</div> </div>
<div className="mt-5 grid gap-4"> <div className="mt-3 grid gap-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role name</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Role name</span>
<input <input
value={roleForm.name} value={roleForm.name}
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))} onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
@@ -311,7 +412,7 @@ export function UserManagementPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea <textarea
value={roleForm.description} value={roleForm.description}
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))} onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
@@ -321,11 +422,11 @@ export function UserManagementPage() {
</label> </label>
</div> </div>
<div className="mt-5"> <div className="mt-3">
<p className="text-sm font-semibold text-text">Role permissions</p> <p className="section-kicker">ROLE PERMISSIONS</p>
<div className="mt-3 grid gap-3 md:grid-cols-2"> <div className="mt-3 grid gap-2 md:grid-cols-2">
{permissions.map((permission) => ( {permissions.map((permission) => (
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text"> <label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
<input <input
type="checkbox" type="checkbox"
checked={roleForm.permissionKeys.includes(permission.key)} checked={roleForm.permissionKeys.includes(permission.key)}
@@ -340,9 +441,9 @@ export function UserManagementPage() {
</div> </div>
</div> </div>
<div className="mt-5 grid gap-3 md:grid-cols-3"> <div className="mt-3 grid gap-2 md:grid-cols-3">
{roles.map((role) => ( {roles.map((role) => (
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3"> <div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<p className="text-sm font-semibold text-text">{role.name}</p> <p className="text-sm font-semibold text-text">{role.name}</p>
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p> <p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p> <p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
@@ -350,7 +451,7 @@ export function UserManagementPage() {
))} ))}
</div> </div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3"> <div className="mt-3 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{status}</span> <span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white"> <button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedRoleId === "new" ? "Create role" : "Save role"} {selectedRoleId === "new" ? "Create role" : "Save role"}
@@ -358,6 +459,210 @@ export function UserManagementPage() {
</div> </div>
</form> </form>
</section> </section>
<section className="surface-panel backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="section-kicker">SESSIONS</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={sessionQuery}
onChange={(event) => setSessionQuery(event.target.value)}
placeholder="User, email, IP, agent, review reason"
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">User</span>
<select
value={sessionUserFilter}
onChange={(event) => setSessionUserFilter(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="all">All users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.firstName} {user.lastName}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={sessionStatusFilter}
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="ALL">All statuses</option>
<option value="ACTIVE">Active</option>
<option value="EXPIRED">Expired</option>
<option value="REVOKED">Revoked</option>
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Review</span>
<select
value={sessionReviewFilter}
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="ALL">All sessions</option>
<option value="REVIEW">Needs review</option>
<option value="NORMAL">Normal</option>
</select>
</label>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Active</p>
<p className="mt-2 text-2xl font-bold text-text">{activeSessionCount}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Revoked</p>
<p className="mt-2 text-2xl font-bold text-text">{revokedSessionCount}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Expired</p>
<p className="mt-2 text-2xl font-bold text-text">{expiredSessionCount}</p>
</div>
<div className="rounded-2xl border border-amber-300/60 bg-amber-50 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Needs Review</p>
<p className="mt-2 text-2xl font-bold text-amber-900">{reviewSessionCount}</p>
</div>
</div>
<div className="mt-5 grid gap-3">
{filteredSessions.map((session) => (
<div key={session.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-text">{session.userName}</p>
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
{session.status}
</span>
{session.reviewState === "REVIEW" ? (
<span className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">
Review
</span>
) : null}
{session.isCurrent ? (
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
Current
</span>
) : null}
</div>
<p className="mt-1 text-sm text-muted">{session.userEmail}</p>
<div className="mt-3 grid gap-2 text-xs text-muted md:grid-cols-2 xl:grid-cols-4">
<p>Started: {new Date(session.createdAt).toLocaleString()}</p>
<p>Last seen: {new Date(session.lastSeenAt).toLocaleString()}</p>
<p>Expires: {new Date(session.expiresAt).toLocaleString()}</p>
<p>IP: {session.ipAddress || "Unknown"}</p>
</div>
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
{session.reviewReasons.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{session.reviewReasons.map((reason) => (
<span key={reason} className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-800">
{reason}
</span>
))}
</div>
) : null}
{session.revokedAt ? (
<p className="mt-2 text-xs text-muted">
Revoked {new Date(session.revokedAt).toLocaleString()}
{session.revokedByName ? ` by ${session.revokedByName}` : ""}.
{session.revokedReason ? ` ${session.revokedReason}` : ""}
</p>
) : null}
</div>
{session.status === "ACTIVE" ? (
<button
type="button"
onClick={() =>
setPendingConfirmation({
kind: "revoke-session",
title: session.isCurrent ? "Revoke current session" : `Revoke session for ${session.userName}`,
description: session.isCurrent
? "Revoke the session you are using right now. Your current browser session will lose access immediately."
: `Revoke the selected active session for ${session.userEmail}.`,
impact: "The selected token becomes unusable immediately.",
recovery: "The user can sign in again unless the account itself is inactive. Review the remaining session list after revocation.",
confirmLabel: "Revoke session",
confirmationLabel: session.isCurrent ? "Type REVOKE to confirm:" : undefined,
confirmationValue: session.isCurrent ? "REVOKE" : undefined,
sessionId: session.id,
})
}
className="rounded-2xl border border-red-300 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
>
Revoke session
</button>
) : null}
</div>
</div>
))}
{filteredSessions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line/70 bg-page/40 px-3 py-6 text-sm text-muted">
No sessions match the current filter.
</div>
) : null}
</div>
</section>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm admin action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={isConfirmingAction}
onClose={() => {
if (!isConfirmingAction) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
setIsConfirmingAction(true);
try {
if (pendingConfirmation.kind === "deactivate-user" && pendingConfirmation.userId) {
await saveUser();
}
if (pendingConfirmation.kind === "revoke-session" && pendingConfirmation.sessionId) {
const isCurrentSession = sessions.find((session) => session.id === pendingConfirmation.sessionId)?.isCurrent ?? false;
await handleSessionRevoke(pendingConfirmation.sessionId, isCurrentSession);
}
if (
pendingConfirmation.kind === "deactivate-user" &&
pendingConfirmation.userId &&
pendingConfirmation.userId === authUser?.id
) {
setStatus("Your own account was deactivated. Sign-in will fail after this session ends unless another admin re-enables the account.");
}
setPendingConfirmation(null);
} finally {
setIsConfirmingAction(false);
}
}}
/>
</div> </div>
); );
} }

View File

@@ -1,44 +1,107 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { ShipmentDetailDto, ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js"; import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { ShipmentDetailDto, ShipmentPickInput, ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react"; 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 { api, ApiError } from "../../lib/api"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel"; import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { api, ApiError } from "../../lib/api";
import { shipmentStatusOptions } from "./config"; import { shipmentStatusOptions } from "./config";
import { ShipmentStatusBadge } from "./ShipmentStatusBadge"; import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
function buildInitialPickForm(
shipment: ShipmentDetailDto | null,
locationOptions: WarehouseLocationOptionDto[],
current?: ShipmentPickInput
): ShipmentPickInput {
const remainingLine = shipment?.lines.find((line) => line.remainingQuantity > 0) ?? shipment?.lines[0] ?? null;
const fallbackLocation =
locationOptions.find((location) => location.warehouseId === current?.warehouseId) ?? locationOptions[0] ?? null;
return {
salesOrderLineId: current?.salesOrderLineId && shipment?.lines.some((line) => line.salesOrderLineId === current.salesOrderLineId)
? current.salesOrderLineId
: remainingLine?.salesOrderLineId ?? "",
warehouseId: current?.warehouseId || fallbackLocation?.warehouseId || "",
locationId: current?.locationId || fallbackLocation?.locationId || "",
quantity: current?.quantity ?? Math.min(remainingLine?.remainingQuantity ?? 1, 1),
notes: current?.notes ?? "",
};
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString();
}
export function ShipmentDetailPage() { export function ShipmentDetailPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const { shipmentId } = useParams(); const { shipmentId } = useParams();
const [shipment, setShipment] = useState<ShipmentDetailDto | null>(null); const [shipment, setShipment] = useState<ShipmentDetailDto | null>(null);
const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]); const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [pickForm, setPickForm] = useState<ShipmentPickInput>({
salesOrderLineId: "",
warehouseId: "",
locationId: "",
quantity: 1,
notes: "",
});
const [status, setStatus] = useState("Loading shipment..."); const [status, setStatus] = useState("Loading shipment...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isPostingPick, setIsPostingPick] = useState(false);
const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null); const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus: ShipmentStatus;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false; const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
async function loadShipmentDetail(activeToken: string, activeShipmentId: string) {
const [nextShipment, nextLocationOptions] = await Promise.all([
api.getShipment(activeToken, activeShipmentId),
canManage ? api.getWarehouseLocationOptions(activeToken) : Promise.resolve<WarehouseLocationOptionDto[]>([]),
]);
const shipments = await api.getShipments(activeToken, { salesOrderId: nextShipment.salesOrderId });
setShipment(nextShipment);
setLocationOptions(nextLocationOptions);
setRelatedShipments(shipments.filter((candidate) => candidate.id !== activeShipmentId));
setPickForm((current) => buildInitialPickForm(nextShipment, nextLocationOptions, current));
setStatus("Shipment loaded.");
}
useEffect(() => { useEffect(() => {
if (!token || !shipmentId) { if (!token || !shipmentId) {
return; return;
} }
api.getShipment(token, shipmentId) loadShipmentDetail(token, shipmentId).catch((error: unknown) => {
.then((nextShipment) => { const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
setShipment(nextShipment); setStatus(message);
setStatus("Shipment loaded."); });
return api.getShipments(token, { salesOrderId: nextShipment.salesOrderId }); }, [shipmentId, token, canManage]);
})
.then((shipments) => setRelatedShipments(shipments.filter((candidate) => candidate.id !== shipmentId)))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
setStatus(message);
});
}, [shipmentId, token]);
async function handleStatusChange(nextStatus: ShipmentStatus) { const selectedLine = shipment?.lines.find((line) => line.salesOrderLineId === pickForm.salesOrderLineId) ?? null;
const availableLocations = locationOptions.filter((location) => !pickForm.warehouseId || location.warehouseId === pickForm.warehouseId);
const warehouseOptions = Array.from(
new Map(locationOptions.map((location) => [location.warehouseId, { id: location.warehouseId, label: `${location.warehouseCode} · ${location.warehouseName}` }])).values()
);
const totalOrderedQuantity = shipment?.lines.reduce((sum, line) => sum + line.orderedQuantity, 0) ?? 0;
const totalPickedQuantity = shipment?.lines.reduce((sum, line) => sum + line.pickedQuantity, 0) ?? 0;
async function applyStatusChange(nextStatus: ShipmentStatus) {
if (!token || !shipment) { if (!token || !shipment) {
return; return;
} }
@@ -48,7 +111,8 @@ export function ShipmentDetailPage() {
try { try {
const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus); const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus);
setShipment(nextShipment); setShipment(nextShipment);
setStatus("Shipment status updated."); setPickForm((current) => buildInitialPickForm(nextShipment, locationOptions, current));
setStatus("Shipment status updated. Verify carrier paperwork, inventory issue progress, and sales-order expectations.");
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update shipment status."; const message = error instanceof ApiError ? error.message : "Unable to update shipment status.";
setStatus(message); setStatus(message);
@@ -57,6 +121,29 @@ export function ShipmentDetailPage() {
} }
} }
function handleStatusChange(nextStatus: ShipmentStatus) {
if (!shipment) {
return;
}
const label = shipmentStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
setPendingConfirmation({
title: `Set shipment to ${label}`,
description: `Update shipment ${shipment.shipmentNumber} from ${shipment.status} to ${nextStatus}.`,
impact:
nextStatus === "DELIVERED"
? "This marks delivery complete and can affect customer communication, project delivery status, and shipment closeout review."
: nextStatus === "SHIPPED"
? "This marks the shipment as outbound and should only happen after stock has been picked and packed from real inventory locations."
: "This changes the logistics state used by related shipping and sales workflows.",
recovery: "If the status is wrong, return the shipment to the correct state and confirm pick quantities still match the physical shipment.",
confirmLabel: `Set ${label}`,
confirmationLabel: nextStatus === "DELIVERED" ? "Type shipment number to confirm:" : undefined,
confirmationValue: nextStatus === "DELIVERED" ? shipment.shipmentNumber : undefined,
nextStatus,
});
}
async function handleOpenDocument(kind: "packing-slip" | "label" | "bol") { async function handleOpenDocument(kind: "packing-slip" | "label" | "bol") {
if (!token || !shipment) { if (!token || !shipment) {
return; return;
@@ -102,19 +189,45 @@ export function ShipmentDetailPage() {
} }
} }
async function handlePostPick() {
if (!token || !shipment || !selectedLine) {
return;
}
setIsPostingPick(true);
setStatus("Posting shipment pick and issuing stock...");
try {
const nextShipment = await api.postShipmentPick(token, shipment.id, {
...pickForm,
quantity: Number(pickForm.quantity),
});
setShipment(nextShipment);
setPickForm(buildInitialPickForm(nextShipment, locationOptions));
setStatus("Shipment pick posted. Inventory was issued from the selected stock location.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post shipment pick.";
setStatus(message);
} finally {
setIsPostingPick(false);
}
}
if (!shipment) { if (!shipment) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
return ( return (
<section className="space-y-4"> <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="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment</p> <p className="section-kicker">SHIPMENT</p>
<h3 className="mt-2 text-xl font-bold text-text">{shipment.shipmentNumber}</h3> <h3 className="module-title">{shipment.shipmentNumber}</h3>
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} · {shipment.customerName}</p> <p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} / {shipment.customerName}</p>
<div className="mt-3"><ShipmentStatusBadge status={shipment.status} /></div> <div className="mt-3 flex flex-wrap items-center gap-3">
<ShipmentStatusBadge status={shipment.status} />
<span className="text-xs text-muted">{status}</span>
</div>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/shipping/shipments" 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 shipments</Link> <Link to="/shipping/shipments" 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 shipments</Link>
@@ -134,12 +247,12 @@ export function ShipmentDetailPage() {
</div> </div>
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p> <p className="section-kicker">QUICK ACTIONS</p>
<p className="mt-2 text-sm text-muted">Update shipment status without opening the editor.</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{shipmentStatusOptions.map((option) => ( {shipmentStatusOptions.map((option) => (
@@ -151,46 +264,263 @@ export function ShipmentDetailPage() {
</div> </div>
</section> </section>
) : null} ) : 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">Carrier</p><div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div></article> <section className="grid gap-2 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">Service</p><div className="mt-2 text-base font-bold text-text">{shipment.serviceLevel || "Not set"}</div></article> <article className="surface-panel-tight">
<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">Tracking</p><div className="mt-2 text-base font-bold text-text">{shipment.trackingNumber || "Not set"}</div></article> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Carrier</p>
<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">Packages</p><div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div></article> <div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<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">Shipment Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timing</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ordered Units</p>
<div className="mt-2 text-base font-bold text-text">{totalOrderedQuantity}</div>
</article>
<article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Picked Units</p>
<div className="mt-2 text-base font-bold text-text">{totalPickedQuantity}</div>
</article>
<article className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Packages</p>
<div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.3fr)_minmax(340px,0.9fr)]">
<article className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div>
<p className="section-kicker">SHIPMENT LINES</p>
</div>
</div>
<div className="mt-5 overflow-x-auto">
<table className="min-w-full divide-y divide-line/60 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-[0.16em] text-muted">
<th className="pb-3 pr-3 font-semibold">Item</th>
<th className="pb-3 pr-3 font-semibold">Description</th>
<th className="pb-3 pr-3 font-semibold">Ordered</th>
<th className="pb-3 pr-3 font-semibold">Picked</th>
<th className="pb-3 font-semibold">Remaining</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{shipment.lines.map((line) => (
<tr key={line.salesOrderLineId}>
<td className="py-3 pr-3 align-top">
<div className="font-semibold text-text">{line.itemSku}</div>
<div className="text-xs text-muted">{line.itemName}</div>
</td>
<td className="py-3 pr-3 align-top text-text">{line.description}</td>
<td className="py-3 pr-3 align-top text-text">{line.orderedQuantity} {line.unitOfMeasure}</td>
<td className="py-3 pr-3 align-top text-text">{line.pickedQuantity} {line.unitOfMeasure}</td>
<td className="py-3 align-top">
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${line.remainingQuantity > 0 ? "bg-amber-500/15 text-amber-700 dark:text-amber-300" : "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"}`}>
{line.remainingQuantity} {line.unitOfMeasure}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
<article className="surface-panel">
<p className="section-kicker">TIMING</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">Ship Date</dt><dd className="mt-1 text-sm text-text">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</dd></div> <div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.createdAt).toLocaleString()}</dd></div> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Updated</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.updatedAt).toLocaleString()}</dd></div> <dd className="mt-1 text-sm text-text">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</dt>
<dd className="mt-1 text-sm text-text">{formatDateTime(shipment.createdAt)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Updated</dt>
<dd className="mt-1 text-sm text-text">{formatDateTime(shipment.updatedAt)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tracking</dt>
<dd className="mt-1 text-sm text-text">{shipment.trackingNumber || "Not set"}</dd>
</div>
</dl> </dl>
</article> </article>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
{canManage ? (
<section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="section-kicker">PICK AND ISSUE FROM STOCK</p>
</div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-2 py-2 text-xs text-muted">
Select line, location, and quantity.
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment line</span>
<select
value={pickForm.salesOrderLineId}
onChange={(event) => {
const nextLine = shipment.lines.find((line) => line.salesOrderLineId === event.target.value) ?? null;
setPickForm((current) => ({
...current,
salesOrderLineId: event.target.value,
quantity: Math.min(nextLine?.remainingQuantity ?? 1, 1),
}));
}}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
{shipment.lines.map((line) => (
<option key={line.salesOrderLineId} value={line.salesOrderLineId}>
{line.itemSku} / remaining {line.remainingQuantity} {line.unitOfMeasure}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse</span>
<select
value={pickForm.warehouseId}
onChange={(event) => {
const nextWarehouseId = event.target.value;
const nextLocation = locationOptions.find((location) => location.warehouseId === nextWarehouseId) ?? null;
setPickForm((current) => ({
...current,
warehouseId: nextWarehouseId,
locationId: nextLocation?.locationId ?? "",
}));
}}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
{warehouseOptions.map((warehouse) => (
<option key={warehouse.id} value={warehouse.id}>
{warehouse.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Location</span>
<select
value={pickForm.locationId}
onChange={(event) => setPickForm((current) => ({ ...current, locationId: event.target.value }))}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
{availableLocations.map((location) => (
<option key={location.locationId} value={location.locationId}>
{location.warehouseCode} / {location.locationCode} / {location.locationName}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
<input
type="number"
min={0.0001}
step="any"
value={pickForm.quantity}
onChange={(event) => setPickForm((current) => ({ ...current, quantity: Number(event.target.value) }))}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
/>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input
type="text"
value={pickForm.notes}
onChange={(event) => setPickForm((current) => ({ ...current, notes: event.target.value }))}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
placeholder="Picker, carton, or handling notes"
/>
</label>
</div>
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm text-muted">
{selectedLine
? `Remaining on selected line: ${selectedLine.remainingQuantity} ${selectedLine.unitOfMeasure}.`
: "Select a shipment line to issue inventory."}
</div>
<button
type="button"
onClick={() => void handlePostPick()}
disabled={
isPostingPick ||
!selectedLine ||
selectedLine.remainingQuantity <= 0 ||
!pickForm.warehouseId ||
!pickForm.locationId ||
pickForm.quantity <= 0
}
className="inline-flex items-center justify-center rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isPostingPick ? "Issuing stock..." : "Post shipment pick"}
</button>
</div>
</section>
) : null}
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<article className="surface-panel">
<div className="flex items-center justify-between gap-3">
<div>
<p className="section-kicker">PICK HISTORY</p>
</div>
</div>
{shipment.picks.length === 0 ? (
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
No shipment picks posted yet.
</div>
) : (
<div className="mt-3 space-y-2">
{shipment.picks.map((pick) => (
<div key={pick.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">{pick.itemSku} / {pick.itemName}</div>
<div className="mt-1 text-xs text-muted">
{pick.quantity} issued from {pick.warehouseCode} / {pick.locationCode}
</div>
</div>
<div className="text-right text-xs text-muted">
<div>{pick.createdByName}</div>
<div className="mt-1">{formatDateTime(pick.createdAt)}</div>
</div>
</div>
<div className="mt-2 text-sm text-text">{pick.notes || "No pick notes."}</div>
</div>
))}
</div>
)}
</article>
<article className="surface-panel">
<p className="section-kicker">SHIPMENT NOTES</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
</article>
</div>
<section className="surface-panel">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Related Shipments</p> <p className="section-kicker">RELATED SHIPMENTS</p>
<p className="mt-2 text-sm text-muted">Other shipments already tied to this sales order.</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link to={`/shipping/shipments/new?orderId=${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add another shipment</Link> <Link to={`/shipping/shipments/new?orderId=${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add another shipment</Link>
) : null} ) : null}
</div> </div>
{relatedShipments.length === 0 ? ( {relatedShipments.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 additional shipments exist for this sales order.</div> <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No additional shipments.</div>
) : ( ) : (
<div className="mt-6 space-y-3"> <div className="mt-3 space-y-2">
{relatedShipments.map((related) => ( {relatedShipments.map((related) => (
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"> <Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-[18px] 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 className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="font-semibold text-text">{related.shipmentNumber}</div> <div className="font-semibold text-text">{related.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{related.carrier || "Carrier not set"} · {related.trackingNumber || "No tracking"}</div> <div className="mt-1 text-xs text-muted">{related.carrier || "Carrier not set"} / {related.trackingNumber || "No tracking"}</div>
</div> </div>
<ShipmentStatusBadge status={related.status} /> <ShipmentStatusBadge status={related.status} />
</div> </div>
@@ -199,6 +529,7 @@ export function ShipmentDetailPage() {
</div> </div>
)} )}
</section> </section>
<FileAttachmentsPanel <FileAttachmentsPanel
ownerType="SHIPMENT" ownerType="SHIPMENT"
ownerId={shipment.id} ownerId={shipment.id}
@@ -207,6 +538,31 @@ export function ShipmentDetailPage() {
description="Store carrier paperwork, signed delivery records, bills of lading, and related logistics support files on the shipment record." description="Store carrier paperwork, signed delivery records, bills of lading, and related logistics support files on the shipment record."
emptyMessage="No logistics attachments have been uploaded for this shipment yet." emptyMessage="No logistics attachments have been uploaded for this shipment yet."
/> />
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm shipment action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={isUpdatingStatus}
onClose={() => {
if (!isUpdatingStatus) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
await applyStatusChange(pendingConfirmation.nextStatus);
setPendingConfirmation(null);
}}
/>
</section> </section>
); );
} }

View File

@@ -1,6 +1,6 @@
import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js"; import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { 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";
@@ -85,20 +85,24 @@ export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
} }
} }
function closeEditor() {
navigate(mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`);
}
return ( return (
<form className="space-y-6" onSubmit={handleSubmit}> <form className="page-stack" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Editor</p> <p className="section-kicker">SHIPPING EDITOR</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3> <h3 className="module-title">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
</div> </div>
<Link to={mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel Cancel
</Link> </button>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="space-y-3 surface-panel">
<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>
<div className="relative"> <div className="relative">
@@ -187,7 +191,7 @@ export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
</div> </div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <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={4} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="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"> <div className="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> <span className="min-w-0 text-sm text-muted">{status}</span>
@@ -199,3 +203,4 @@ export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
</form> </form>
); );
} }

View File

@@ -38,12 +38,11 @@ export function ShipmentListPage() {
}, [searchTerm, statusFilter, token]); }, [searchTerm, statusFilter, token]);
return ( return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel"> <section className="surface-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p> <p className="section-kicker">SHIPPING</p>
<h3 className="mt-2 text-lg font-bold text-text">Shipments</h3> <h3 className="module-title">SHIPMENTS</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Outbound shipment records tied to sales orders, carriers, and tracking details.</p>
</div> </div>
{canManage ? ( {canManage ? (
<Link to="/shipping/shipments/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white"> <Link to="/shipping/shipments/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
@@ -51,7 +50,7 @@ export function ShipmentListPage() {
</Link> </Link>
) : null} ) : null}
</div> </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]"> <div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input <input
@@ -76,11 +75,11 @@ export function ShipmentListPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div> <div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
{shipments.length === 0 ? ( {shipments.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 shipments have been added yet.</div> <div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No shipments have been added yet.</div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr> <tr>
@@ -114,3 +113,4 @@ export function ShipmentListPage() {
</section> </section>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,16 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "../theme/ThemeProvider"; import { ThemeProvider } from "../theme/ThemeProvider";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
describe("ThemeToggle", () => { describe("ThemeToggle", () => {
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute("style");
document.documentElement.classList.remove("dark");
});
it("toggles the html dark class", () => { it("toggles the html dark class", () => {
render( render(
<ThemeProvider> <ThemeProvider>
@@ -16,5 +22,31 @@ describe("ThemeToggle", () => {
expect(document.documentElement.classList.contains("dark")).toBe(true); expect(document.documentElement.classList.contains("dark")).toBe(true);
}); });
});
it("hydrates persisted brand theme values on startup", async () => {
window.localStorage.setItem(
"mrp.theme.brand-profile",
JSON.stringify({
theme: {
primaryColor: "#112233",
accentColor: "#445566",
surfaceColor: "#778899",
fontFamily: "Manrope",
},
})
);
render(
<ThemeProvider>
<div>Theme</div>
</ThemeProvider>
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--color-brand")).toBe("17 34 51");
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("68 85 102");
expect(document.documentElement.style.getPropertyValue("--color-surface-brand")).toBe("119 136 153");
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("Manrope");
});
});
});

View File

@@ -13,6 +13,14 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null); const ThemeContext = createContext<ThemeContextValue | null>(null);
const storageKey = "mrp.theme.mode"; const storageKey = "mrp.theme.mode";
const brandProfileKey = "mrp.theme.brand-profile";
function applyThemeVariables(profile: Pick<CompanyProfileDto, "theme">) {
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
document.documentElement.style.setProperty("--color-surface-brand", hexToRgbTriplet(profile.theme.surfaceColor));
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<ThemeMode>(() => { const [mode, setMode] = useState<ThemeMode>(() => {
@@ -20,6 +28,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
return stored === "dark" ? "dark" : "light"; return stored === "dark" ? "dark" : "light";
}); });
useEffect(() => {
const storedBrandProfile = window.localStorage.getItem(brandProfileKey);
if (!storedBrandProfile) {
return;
}
try {
const parsed = JSON.parse(storedBrandProfile) as Pick<CompanyProfileDto, "theme">;
applyThemeVariables(parsed);
} catch {
window.localStorage.removeItem(brandProfileKey);
}
}, []);
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("dark", mode === "dark"); document.documentElement.classList.toggle("dark", mode === "dark");
document.documentElement.style.colorScheme = mode; document.documentElement.style.colorScheme = mode;
@@ -31,10 +53,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
return; return;
} }
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor)); applyThemeVariables(profile);
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor)); window.localStorage.setItem(brandProfileKey, JSON.stringify({ theme: profile.theme }));
document.documentElement.style.setProperty("--color-surface-brand", hexToRgbTriplet(profile.theme.surfaceColor));
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
}; };
const value = useMemo( const value = useMemo(

1
fabdash Submodule

Submodule fabdash added at fe4d8b120c

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "AuthSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipAddress" TEXT,
"userAgent" TEXT,
"revokedAt" DATETIME,
"revokedById" TEXT,
"revokedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "AuthSession_revokedById_fkey" FOREIGN KEY ("revokedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "AuthSession_userId_createdAt_idx" ON "AuthSession"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "AuthSession_expiresAt_idx" ON "AuthSession"("expiresAt");
-- CreateIndex
CREATE INDEX "AuthSession_revokedAt_idx" ON "AuthSession"("revokedAt");

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "PurchaseOrderRevision" (
"id" TEXT NOT NULL PRIMARY KEY,
"purchaseOrderId" TEXT NOT NULL,
"revisionNumber" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"snapshot" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrderRevision_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseOrderRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PurchaseOrderRevision_purchaseOrderId_revisionNumber_key" ON "PurchaseOrderRevision"("purchaseOrderId", "revisionNumber");
-- CreateIndex
CREATE INDEX "PurchaseOrderRevision_purchaseOrderId_createdAt_idx" ON "PurchaseOrderRevision"("purchaseOrderId", "createdAt");

View File

@@ -0,0 +1,78 @@
-- CreateTable
CREATE TABLE "InventorySkuFamily" (
"id" TEXT NOT NULL PRIMARY KEY,
"code" TEXT NOT NULL,
"sequenceCode" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"nextSequenceNumber" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "InventorySkuNode" (
"id" TEXT NOT NULL PRIMARY KEY,
"familyId" TEXT NOT NULL,
"parentNodeId" TEXT,
"code" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT NOT NULL,
"path" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventorySkuNode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventorySkuNode_parentNodeId_fkey" FOREIGN KEY ("parentNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_InventoryItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"sku" TEXT NOT NULL,
"skuFamilyId" TEXT,
"skuNodeId" TEXT,
"skuSequenceNumber" INTEGER,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"isSellable" BOOLEAN NOT NULL DEFAULT true,
"isPurchasable" BOOLEAN NOT NULL DEFAULT true,
"preferredVendorId" TEXT,
"defaultCost" REAL,
"defaultPrice" REAL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryItem_preferredVendorId_fkey" FOREIGN KEY ("preferredVendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryItem_skuFamilyId_fkey" FOREIGN KEY ("skuFamilyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryItem_skuNodeId_fkey" FOREIGN KEY ("skuNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_InventoryItem" ("createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt") SELECT "createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt" FROM "InventoryItem";
DROP TABLE "InventoryItem";
ALTER TABLE "new_InventoryItem" RENAME TO "InventoryItem";
CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
CREATE INDEX "InventoryItem_skuFamilyId_idx" ON "InventoryItem"("skuFamilyId");
CREATE INDEX "InventoryItem_skuNodeId_idx" ON "InventoryItem"("skuNodeId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuFamily_code_key" ON "InventorySkuFamily"("code");
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuFamily_sequenceCode_key" ON "InventorySkuFamily"("sequenceCode");
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuNode_familyId_path_key" ON "InventorySkuNode"("familyId", "path");
-- CreateIndex
CREATE INDEX "InventorySkuNode_familyId_parentNodeId_sortOrder_idx" ON "InventorySkuNode"("familyId", "parentNodeId", "sortOrder");

View File

@@ -0,0 +1,16 @@
CREATE TABLE "ProjectMilestone" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL,
"dueDate" DATETIME,
"completedAt" DATETIME,
"notes" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ProjectMilestone_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "ProjectMilestone_projectId_sortOrder_idx" ON "ProjectMilestone"("projectId", "sortOrder");
CREATE INDEX "ProjectMilestone_projectId_dueDate_idx" ON "ProjectMilestone"("projectId", "dueDate");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "ManufacturingStation" ADD COLUMN "dailyCapacityMinutes" INTEGER NOT NULL DEFAULT 480;
ALTER TABLE "ManufacturingStation" ADD COLUMN "parallelCapacity" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "ManufacturingStation" ADD COLUMN "workingDays" TEXT NOT NULL DEFAULT '1,2,3,4,5';

View File

@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "WorkOrderOperation" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING';
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualStart" DATETIME;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualEnd" DATETIME;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualMinutes" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "WorkOrderOperationLaborEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"operationId" TEXT NOT NULL,
"minutes" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperationLaborEntry_operationId_fkey" FOREIGN KEY ("operationId") REFERENCES "WorkOrderOperation" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperationLaborEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "WorkOrderOperationLaborEntry_operationId_createdAt_idx" ON "WorkOrderOperationLaborEntry"("operationId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderOperationLaborEntry_createdById_createdAt_idx" ON "WorkOrderOperationLaborEntry"("createdById", "createdAt");

View File

@@ -0,0 +1,44 @@
-- AlterTable
ALTER TABLE "WorkOrderOperation" ADD COLUMN "assignedOperatorId" TEXT;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "activeTimerStartedAt" DATETIME;
-- CreateIndex
CREATE INDEX "WorkOrderOperation_assignedOperatorId_plannedStart_idx" ON "WorkOrderOperation"("assignedOperatorId", "plannedStart");
-- AddForeignKey
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_WorkOrderOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"assignedOperatorId" TEXT,
"sequence" INTEGER NOT NULL,
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedStart" DATETIME NOT NULL,
"plannedEnd" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"actualStart" DATETIME,
"actualEnd" DATETIME,
"actualMinutes" INTEGER NOT NULL DEFAULT 0,
"activeTimerStartedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_assignedOperatorId_fkey" FOREIGN KEY ("assignedOperatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_WorkOrderOperation" ("actualEnd", "actualMinutes", "actualStart", "activeTimerStartedAt", "assignedOperatorId", "createdAt", "id", "moveMinutes", "notes", "plannedEnd", "plannedMinutes", "plannedStart", "runMinutesPerUnit", "sequence", "setupMinutes", "stationId", "status", "updatedAt", "workOrderId")
SELECT "actualEnd", "actualMinutes", "actualStart", "activeTimerStartedAt", "assignedOperatorId", "createdAt", "id", "moveMinutes", "notes", "plannedEnd", "plannedMinutes", "plannedStart", "runMinutesPerUnit", "sequence", "setupMinutes", "stationId", "status", "updatedAt", "workOrderId" FROM "WorkOrderOperation";
DROP TABLE "WorkOrderOperation";
ALTER TABLE "new_WorkOrderOperation" RENAME TO "WorkOrderOperation";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- RecreateIndex
CREATE INDEX "WorkOrderOperation_workOrderId_sequence_idx" ON "WorkOrderOperation"("workOrderId", "sequence");
CREATE INDEX "WorkOrderOperation_stationId_plannedStart_idx" ON "WorkOrderOperation"("stationId", "plannedStart");
CREATE INDEX "WorkOrderOperation_assignedOperatorId_plannedStart_idx" ON "WorkOrderOperation"("assignedOperatorId", "plannedStart");

View File

@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "ShipmentPick" (
"id" TEXT NOT NULL PRIMARY KEY,
"shipmentId" TEXT NOT NULL,
"salesOrderLineId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ShipmentPick_shipmentId_fkey" FOREIGN KEY ("shipmentId") REFERENCES "Shipment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_salesOrderLineId_fkey" FOREIGN KEY ("salesOrderLineId") REFERENCES "SalesOrderLine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "ShipmentPick_shipmentId_createdAt_idx" ON "ShipmentPick"("shipmentId", "createdAt");
-- CreateIndex
CREATE INDEX "ShipmentPick_salesOrderLineId_createdAt_idx" ON "ShipmentPick"("salesOrderLineId", "createdAt");
-- CreateIndex
CREATE INDEX "ShipmentPick_warehouseId_locationId_createdAt_idx" ON "ShipmentPick"("warehouseId", "locationId", "createdAt");

View File

@@ -0,0 +1,83 @@
-- CreateTable
CREATE TABLE "FinanceProfile" (
"id" TEXT NOT NULL PRIMARY KEY,
"currencyCode" TEXT NOT NULL DEFAULT 'USD',
"standardLaborRatePerHour" REAL NOT NULL DEFAULT 45,
"overheadRatePerHour" REAL NOT NULL DEFAULT 18,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "FinanceCustomerPayment" (
"id" TEXT NOT NULL PRIMARY KEY,
"salesOrderId" TEXT NOT NULL,
"paymentType" TEXT NOT NULL,
"paymentMethod" TEXT NOT NULL,
"paymentDate" DATETIME NOT NULL,
"amount" REAL NOT NULL,
"reference" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "FinanceCustomerPayment_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "FinanceCustomerPayment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "FinanceManufacturingCostSnapshot" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"materialCost" REAL NOT NULL DEFAULT 0,
"laborCost" REAL NOT NULL DEFAULT 0,
"overheadCost" REAL NOT NULL DEFAULT 0,
"totalCost" REAL NOT NULL DEFAULT 0,
"materialIssueCount" INTEGER NOT NULL DEFAULT 0,
"laborEntryCount" INTEGER NOT NULL DEFAULT 0,
"calculatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "FinanceManufacturingCostSnapshot_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CapexEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"category" TEXT NOT NULL,
"status" TEXT NOT NULL,
"vendorId" TEXT,
"purchaseOrderId" TEXT,
"plannedAmount" REAL NOT NULL,
"actualAmount" REAL NOT NULL,
"requestDate" DATETIME NOT NULL,
"targetInServiceDate" DATETIME,
"purchasedAt" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CapexEntry_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "CapexEntry_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "FinanceCustomerPayment_salesOrderId_paymentDate_idx" ON "FinanceCustomerPayment"("salesOrderId", "paymentDate");
-- CreateIndex
CREATE INDEX "FinanceCustomerPayment_createdAt_idx" ON "FinanceCustomerPayment"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "FinanceManufacturingCostSnapshot_workOrderId_key" ON "FinanceManufacturingCostSnapshot"("workOrderId");
-- CreateIndex
CREATE INDEX "FinanceManufacturingCostSnapshot_calculatedAt_idx" ON "FinanceManufacturingCostSnapshot"("calculatedAt");
-- CreateIndex
CREATE INDEX "CapexEntry_status_requestDate_idx" ON "CapexEntry"("status", "requestDate");
-- CreateIndex
CREATE INDEX "CapexEntry_vendorId_createdAt_idx" ON "CapexEntry"("vendorId", "createdAt");
-- CreateIndex
CREATE INDEX "CapexEntry_purchaseOrderId_createdAt_idx" ON "CapexEntry"("purchaseOrderId", "createdAt");

View File

@@ -0,0 +1,38 @@
ALTER TABLE "PurchaseOrder" ADD COLUMN "projectId" TEXT REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "PurchaseOrder_projectId_issueDate_idx" ON "PurchaseOrder"("projectId", "issueDate");
UPDATE "WorkOrder"
SET "projectId" = (
SELECT "Project"."id"
FROM "Project"
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
ORDER BY "Project"."createdAt" ASC
LIMIT 1
)
WHERE "projectId" IS NULL
AND "salesOrderId" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "Project"
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
);
UPDATE "PurchaseOrder"
SET "projectId" = (
SELECT "Project"."id"
FROM "PurchaseOrderLine"
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
ORDER BY "Project"."createdAt" ASC
LIMIT 1
)
WHERE "projectId" IS NULL
AND EXISTS (
SELECT 1
FROM "PurchaseOrderLine"
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE "WorkOrder" ADD COLUMN "holdReason" TEXT;

View File

@@ -18,16 +18,23 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
userRoles UserRole[] userRoles UserRole[]
authSessions AuthSession[] @relation("AuthSessionUser")
revokedAuthSessions AuthSession[] @relation("AuthSessionRevokedBy")
contactEntries CrmContactEntry[] contactEntries CrmContactEntry[]
inventoryTransactions InventoryTransaction[] inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[] purchaseReceipts PurchaseReceipt[]
ownedProjects Project[] @relation("ProjectOwner") ownedProjects Project[] @relation("ProjectOwner")
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[] workOrderCompletions WorkOrderCompletion[]
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
assignedWorkOrderOperations WorkOrderOperation[]
shipmentPicks ShipmentPick[]
financeCustomerPayments FinanceCustomerPayment[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy") approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy") approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy") salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy") salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
purchaseOrderRevisionsCreated PurchaseOrderRevision[]
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy") inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
auditEvents AuditEvent[] auditEvents AuditEvent[]
} }
@@ -72,6 +79,26 @@ model RolePermission {
@@id([roleId, permissionId]) @@id([roleId, permissionId])
} }
model AuthSession {
id String @id @default(cuid())
userId String
expiresAt DateTime
lastSeenAt DateTime @default(now())
ipAddress String?
userAgent String?
revokedAt DateTime?
revokedById String?
revokedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation("AuthSessionUser", fields: [userId], references: [id], onDelete: Cascade)
revokedBy User? @relation("AuthSessionRevokedBy", fields: [revokedById], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([expiresAt])
@@index([revokedAt])
}
model CompanyProfile { model CompanyProfile {
id String @id @default(cuid()) id String @id @default(cuid())
companyName String companyName String
@@ -115,6 +142,9 @@ model FileAttachment {
model InventoryItem { model InventoryItem {
id String @id @default(cuid()) id String @id @default(cuid())
sku String @unique sku String @unique
skuFamilyId String?
skuNodeId String?
skuSequenceNumber Int?
name String name String
description String description String
type String type String
@@ -136,12 +166,53 @@ model InventoryItem {
purchaseOrderLines PurchaseOrderLine[] purchaseOrderLines PurchaseOrderLine[]
workOrders WorkOrder[] workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
shipmentPicks ShipmentPick[]
operations InventoryItemOperation[] operations InventoryItemOperation[]
reservations InventoryReservation[] reservations InventoryReservation[]
transfers InventoryTransfer[] transfers InventoryTransfer[]
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull) preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
skuFamily InventorySkuFamily? @relation(fields: [skuFamilyId], references: [id], onDelete: SetNull)
skuNode InventorySkuNode? @relation(fields: [skuNodeId], references: [id], onDelete: SetNull)
@@index([preferredVendorId]) @@index([preferredVendorId])
@@index([skuFamilyId])
@@index([skuNodeId])
}
model InventorySkuFamily {
id String @id @default(cuid())
code String @unique
sequenceCode String @unique
name String
description String
nextSequenceNumber Int @default(1)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
nodes InventorySkuNode[]
items InventoryItem[]
}
model InventorySkuNode {
id String @id @default(cuid())
familyId String
parentNodeId String?
code String
label String
description String
path String
level Int
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
family InventorySkuFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
parentNode InventorySkuNode? @relation("InventorySkuNodeTree", fields: [parentNodeId], references: [id], onDelete: Cascade)
childNodes InventorySkuNode[] @relation("InventorySkuNodeTree")
items InventoryItem[]
@@unique([familyId, path])
@@index([familyId, parentNodeId, sortOrder])
} }
model Warehouse { model Warehouse {
@@ -156,6 +227,7 @@ model Warehouse {
purchaseReceipts PurchaseReceipt[] purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[] workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
shipmentPicks ShipmentPick[]
reservations InventoryReservation[] reservations InventoryReservation[]
transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse") transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse")
transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse") transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse")
@@ -227,6 +299,7 @@ model WarehouseLocation {
purchaseReceipts PurchaseReceipt[] purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[] workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
shipmentPicks ShipmentPick[]
reservations InventoryReservation[] reservations InventoryReservation[]
transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation") transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation")
transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation") transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation")
@@ -329,6 +402,7 @@ model Vendor {
contactEntries CrmContactEntry[] contactEntries CrmContactEntry[]
contacts CrmContact[] contacts CrmContact[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
capexEntries CapexEntry[]
preferredSupplyItems InventoryItem[] preferredSupplyItems InventoryItem[]
} }
@@ -424,6 +498,7 @@ model SalesOrder {
revisions SalesOrderRevision[] revisions SalesOrderRevision[]
workOrders WorkOrder[] workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[] purchaseOrderLines PurchaseOrderLine[]
customerPayments FinanceCustomerPayment[]
} }
model SalesOrderLine { model SalesOrderLine {
@@ -441,6 +516,7 @@ model SalesOrderLine {
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
workOrders WorkOrder[] workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[] purchaseOrderLines PurchaseOrderLine[]
shipmentPicks ShipmentPick[]
@@index([orderId, position]) @@index([orderId, position])
} }
@@ -492,10 +568,35 @@ model Shipment {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict) salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
projects Project[] projects Project[]
picks ShipmentPick[]
@@index([salesOrderId, createdAt]) @@index([salesOrderId, createdAt])
} }
model ShipmentPick {
id String @id @default(cuid())
shipmentId String
salesOrderLineId String
itemId String
warehouseId String
locationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shipment Shipment @relation(fields: [shipmentId], references: [id], onDelete: Cascade)
salesOrderLine SalesOrderLine @relation(fields: [salesOrderLineId], references: [id], onDelete: Restrict)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([shipmentId, createdAt])
@@index([salesOrderLineId, createdAt])
@@index([warehouseId, locationId, createdAt])
}
model Project { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
projectNumber String @unique projectNumber String @unique
@@ -517,12 +618,31 @@ model Project {
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull) shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull) owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
workOrders WorkOrder[] workOrders WorkOrder[]
purchaseOrders PurchaseOrder[]
milestones ProjectMilestone[]
@@index([customerId, createdAt]) @@index([customerId, createdAt])
@@index([ownerId, dueDate]) @@index([ownerId, dueDate])
@@index([status, priority]) @@index([status, priority])
} }
model ProjectMilestone {
id String @id @default(cuid())
projectId String
title String
status String
dueDate DateTime?
completedAt DateTime?
notes String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, sortOrder])
@@index([projectId, dueDate])
}
model WorkOrder { model WorkOrder {
id String @id @default(cuid()) id String @id @default(cuid())
workOrderNumber String @unique workOrderNumber String @unique
@@ -533,6 +653,7 @@ model WorkOrder {
warehouseId String warehouseId String
locationId String locationId String
status String status String
holdReason String?
quantity Int quantity Int
completedQuantity Int @default(0) completedQuantity Int @default(0)
dueDate DateTime? dueDate DateTime?
@@ -549,6 +670,7 @@ model WorkOrder {
materialIssues WorkOrderMaterialIssue[] materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[] completions WorkOrderCompletion[]
reservations InventoryReservation[] reservations InventoryReservation[]
financeCostSnapshot FinanceManufacturingCostSnapshot?
@@index([itemId, createdAt]) @@index([itemId, createdAt])
@@index([projectId, dueDate]) @@index([projectId, dueDate])
@@ -564,6 +686,9 @@ model ManufacturingStation {
name String name String
description String description String
queueDays Int @default(0) queueDays Int @default(0)
dailyCapacityMinutes Int @default(480)
parallelCapacity Int @default(1)
workingDays String @default("1,2,3,4,5")
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -593,6 +718,7 @@ model WorkOrderOperation {
id String @id @default(cuid()) id String @id @default(cuid())
workOrderId String workOrderId String
stationId String stationId String
assignedOperatorId String?
sequence Int sequence Int
setupMinutes Int @default(0) setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0) runMinutesPerUnit Int @default(0)
@@ -601,13 +727,36 @@ model WorkOrderOperation {
plannedStart DateTime plannedStart DateTime
plannedEnd DateTime plannedEnd DateTime
notes String notes String
status String @default("PENDING")
actualStart DateTime?
actualEnd DateTime?
actualMinutes Int @default(0)
activeTimerStartedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict) station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
assignedOperator User? @relation(fields: [assignedOperatorId], references: [id], onDelete: SetNull)
laborEntries WorkOrderOperationLaborEntry[]
@@index([workOrderId, sequence]) @@index([workOrderId, sequence])
@@index([stationId, plannedStart]) @@index([stationId, plannedStart])
@@index([assignedOperatorId, plannedStart])
}
model WorkOrderOperationLaborEntry {
id String @id @default(cuid())
operationId String
minutes Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
operation WorkOrderOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([operationId, createdAt])
@@index([createdById, createdAt])
} }
model WorkOrderMaterialIssue { model WorkOrderMaterialIssue {
@@ -645,10 +794,79 @@ model WorkOrderCompletion {
@@index([workOrderId, createdAt]) @@index([workOrderId, createdAt])
} }
model FinanceProfile {
id String @id @default(cuid())
currencyCode String @default("USD")
standardLaborRatePerHour Float @default(45)
overheadRatePerHour Float @default(18)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FinanceCustomerPayment {
id String @id @default(cuid())
salesOrderId String
paymentType String
paymentMethod String
paymentDate DateTime
amount Float
reference String
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([salesOrderId, paymentDate])
@@index([createdAt])
}
model FinanceManufacturingCostSnapshot {
id String @id @default(cuid())
workOrderId String @unique
materialCost Float @default(0)
laborCost Float @default(0)
overheadCost Float @default(0)
totalCost Float @default(0)
materialIssueCount Int @default(0)
laborEntryCount Int @default(0)
calculatedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([calculatedAt])
}
model CapexEntry {
id String @id @default(cuid())
title String
category String
status String
vendorId String?
purchaseOrderId String?
plannedAmount Float
actualAmount Float
requestDate DateTime
targetInServiceDate DateTime?
purchasedAt DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull)
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id], onDelete: SetNull)
@@index([status, requestDate])
@@index([vendorId, createdAt])
@@index([purchaseOrderId, createdAt])
}
model PurchaseOrder { model PurchaseOrder {
id String @id @default(cuid()) id String @id @default(cuid())
documentNumber String @unique documentNumber String @unique
vendorId String vendorId String
projectId String?
status String status String
issueDate DateTime issueDate DateTime
taxPercent Float @default(0) taxPercent Float @default(0)
@@ -657,8 +875,13 @@ model PurchaseOrder {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict) vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
lines PurchaseOrderLine[] lines PurchaseOrderLine[]
receipts PurchaseReceipt[] receipts PurchaseReceipt[]
revisions PurchaseOrderRevision[]
capexEntries CapexEntry[]
@@index([projectId, issueDate])
} }
model PurchaseOrderLine { model PurchaseOrderLine {
@@ -721,6 +944,22 @@ model PurchaseReceiptLine {
@@index([purchaseOrderLineId]) @@index([purchaseOrderLineId])
} }
model PurchaseOrderRevision {
id String @id @default(cuid())
purchaseOrderId String
revisionNumber Int
reason String
snapshot String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@unique([purchaseOrderId, revisionNumber])
@@index([purchaseOrderId, createdAt])
}
model AuditEvent { model AuditEvent {
id String @id @default(cuid()) id String @id @default(cuid())
actorId String? actorId String?

View File

@@ -9,6 +9,7 @@ import pinoHttp from "pino-http";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { paths } from "./config/paths.js"; import { paths } from "./config/paths.js";
import { verifyToken } from "./lib/auth.js"; import { verifyToken } from "./lib/auth.js";
import { getActiveAuthSession, touchAuthSession } from "./lib/auth-sessions.js";
import { getCurrentUserById } from "./lib/current-user.js"; import { getCurrentUserById } from "./lib/current-user.js";
import { fail, ok } from "./lib/http.js"; import { fail, ok } from "./lib/http.js";
import { recordSupportLog } from "./lib/support-log.js"; import { recordSupportLog } from "./lib/support-log.js";
@@ -17,6 +18,7 @@ import { authRouter } from "./modules/auth/router.js";
import { crmRouter } from "./modules/crm/router.js"; import { crmRouter } from "./modules/crm/router.js";
import { documentsRouter } from "./modules/documents/router.js"; import { documentsRouter } from "./modules/documents/router.js";
import { filesRouter } from "./modules/files/router.js"; import { filesRouter } from "./modules/files/router.js";
import { financeRouter } from "./modules/finance/router.js";
import { ganttRouter } from "./modules/gantt/router.js"; import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js"; import { inventoryRouter } from "./modules/inventory/router.js";
import { manufacturingRouter } from "./modules/manufacturing/router.js"; import { manufacturingRouter } from "./modules/manufacturing/router.js";
@@ -44,10 +46,25 @@ export function createApp() {
try { try {
const token = authHeader.slice("Bearer ".length); const token = authHeader.slice("Bearer ".length);
const payload = verifyToken(token); const payload = verifyToken(token);
const session = await getActiveAuthSession(payload.sid, payload.sub);
if (!session) {
request.authUser = undefined;
request.authSessionId = undefined;
return next();
}
const authUser = await getCurrentUserById(payload.sub); const authUser = await getCurrentUserById(payload.sub);
request.authUser = authUser ?? undefined; if (!authUser) {
request.authUser = undefined;
request.authSessionId = undefined;
return next();
}
request.authUser = authUser;
request.authSessionId = session.id;
void touchAuthSession(session.id).catch(() => undefined);
} catch { } catch {
request.authUser = undefined; request.authUser = undefined;
request.authSessionId = undefined;
} }
next(); next();
@@ -81,6 +98,7 @@ export function createApp() {
app.use("/api/v1/admin", adminRouter); app.use("/api/v1/admin", adminRouter);
app.use("/api/v1", settingsRouter); app.use("/api/v1", settingsRouter);
app.use("/api/v1/files", filesRouter); app.use("/api/v1/files", filesRouter);
app.use("/api/v1/finance", financeRouter);
app.use("/api/v1/crm", crmRouter); app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter); app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/manufacturing", manufacturingRouter); app.use("/api/v1/manufacturing", manufacturingRouter);

View File

@@ -0,0 +1,100 @@
import { prisma } from "./prisma.js";
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
const SESSION_RETENTION_DAYS = 30;
export interface AuthSessionContext {
id: string;
userId: string;
expiresAt: Date;
}
export function getSessionExpiryDate(now = new Date()) {
return new Date(now.getTime() + SESSION_DURATION_MS);
}
export function getSessionRetentionCutoff(now = new Date()) {
return new Date(now.getTime() - SESSION_RETENTION_DAYS * 24 * 60 * 60 * 1000);
}
export async function createAuthSession(input: { userId: string; ipAddress?: string | null; userAgent?: string | null }) {
return prisma.authSession.create({
data: {
userId: input.userId,
expiresAt: getSessionExpiryDate(),
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
},
});
}
export async function getActiveAuthSession(sessionId: string, userId: string): Promise<AuthSessionContext | null> {
const session = await prisma.authSession.findFirst({
where: {
id: sessionId,
userId,
revokedAt: null,
expiresAt: {
gt: new Date(),
},
},
select: {
id: true,
userId: true,
expiresAt: true,
},
});
if (!session) {
return null;
}
return session;
}
export async function touchAuthSession(sessionId: string) {
await prisma.authSession.update({
where: { id: sessionId },
data: {
lastSeenAt: new Date(),
},
});
}
export async function revokeAuthSession(sessionId: string, input: { revokedById?: string | null; reason: string }) {
return prisma.authSession.updateMany({
where: {
id: sessionId,
revokedAt: null,
},
data: {
revokedAt: new Date(),
revokedById: input.revokedById ?? null,
revokedReason: input.reason,
},
});
}
export async function pruneOldAuthSessions() {
const cutoff = getSessionRetentionCutoff();
const result = await prisma.authSession.deleteMany({
where: {
OR: [
{
revokedAt: {
lt: cutoff,
},
},
{
revokedAt: null,
expiresAt: {
lt: cutoff,
},
},
],
},
});
return result.count;
}

View File

@@ -5,14 +5,16 @@ import { env } from "../config/env.js";
interface AuthTokenPayload { interface AuthTokenPayload {
sub: string; sub: string;
sid: string;
email: string; email: string;
permissions: string[]; permissions: string[];
} }
export function signToken(user: AuthUser) { export function signToken(user: AuthUser, sessionId: string) {
return jwt.sign( return jwt.sign(
{ {
sub: user.id, sub: user.id,
sid: sessionId,
email: user.email, email: user.email,
permissions: user.permissions, permissions: user.permissions,
} satisfies AuthTokenPayload, } satisfies AuthTokenPayload,
@@ -24,4 +26,3 @@ export function signToken(user: AuthUser) {
export function verifyToken(token: string) { export function verifyToken(token: string) {
return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload; return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload;
} }

View File

@@ -17,7 +17,9 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data", [permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data",
[permissions.filesRead]: "View attached files", [permissions.filesRead]: "View attached files",
[permissions.filesWrite]: "Upload and manage attached files", [permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View gantt timelines", [permissions.financeRead]: "View finance rollups, payments, and capital plans",
[permissions.financeWrite]: "Manage finance rollups, payments, and capital plans",
[permissions.ganttRead]: "View planning workbench",
[permissions.salesRead]: "View sales data", [permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders", [permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.projectsRead]: "View projects and program records", [permissions.projectsRead]: "View projects and program records",
@@ -123,151 +125,10 @@ export async function bootstrapAppData() {
}); });
} }
if ((await prisma.customer.count()) === 0) { const existingFinanceProfile = await prisma.financeProfile.findFirst();
await prisma.customer.createMany({ if (!existingFinanceProfile) {
data: [ await prisma.financeProfile.create({
{ data: {},
name: "Acme Components",
email: "buyer@acme.example",
phone: "555-0101",
addressLine1: "1 Industrial Road",
addressLine2: "",
city: "Detroit",
state: "MI",
postalCode: "48201",
country: "USA",
notes: "Priority account",
},
{
name: "Northwind Fabrication",
email: "ops@northwind.example",
phone: "555-0120",
addressLine1: "42 Assembly Ave",
addressLine2: "",
city: "Milwaukee",
state: "WI",
postalCode: "53202",
country: "USA",
notes: "Requires ASN notice",
},
],
});
}
if ((await prisma.vendor.count()) === 0) {
await prisma.vendor.create({
data: {
name: "SteelSource Supply",
email: "sales@steelsource.example",
phone: "555-0142",
addressLine1: "77 Mill Street",
addressLine2: "",
city: "Gary",
state: "IN",
postalCode: "46402",
country: "USA",
notes: "Lead time 5 business days",
},
});
}
if ((await prisma.inventoryItem.count()) === 0) {
const plate = await prisma.inventoryItem.create({
data: {
sku: "RM-PLATE-AL-125",
name: "Aluminum Plate 1/8in",
description: "Raw aluminum plate stock for fabricated assemblies.",
type: "PURCHASED",
status: "ACTIVE",
unitOfMeasure: "EA",
isSellable: false,
isPurchasable: true,
defaultCost: 42.5,
defaultPrice: null,
notes: "Primary sheet stock for enclosure fabrication.",
},
});
const fastener = await prisma.inventoryItem.create({
data: {
sku: "HW-SCREW-832",
name: "8-32 Socket Head Screw",
description: "Standard socket head cap screw for enclosure assemblies.",
type: "PURCHASED",
status: "ACTIVE",
unitOfMeasure: "EA",
isSellable: false,
isPurchasable: true,
defaultCost: 0.18,
defaultPrice: null,
notes: "Bulk hardware item.",
},
});
const assembly = await prisma.inventoryItem.create({
data: {
sku: "FG-CTRL-BASE",
name: "Control Base Assembly",
description: "Base enclosure assembly for standard control packages.",
type: "ASSEMBLY",
status: "ACTIVE",
unitOfMeasure: "EA",
isSellable: true,
isPurchasable: false,
defaultCost: 118,
defaultPrice: 249,
notes: "Starter BOM for the inventory foundation slice.",
},
});
await prisma.inventoryBomLine.createMany({
data: [
{
parentItemId: assembly.id,
componentItemId: plate.id,
quantity: 2,
unitOfMeasure: "EA",
notes: "Side panel blanks",
position: 10,
},
{
parentItemId: assembly.id,
componentItemId: fastener.id,
quantity: 12,
unitOfMeasure: "EA",
notes: "General assembly hardware",
position: 20,
},
],
});
}
if ((await prisma.warehouse.count()) === 0) {
await prisma.warehouse.create({
data: {
code: "MAIN",
name: "Main Warehouse",
notes: "Primary stocking location for finished goods and purchased materials.",
locations: {
create: [
{
code: "RECV",
name: "Receiving",
notes: "Initial inbound inspection and receipt staging.",
},
{
code: "STOCK-A1",
name: "Aisle A1",
notes: "General rack storage for standard material.",
},
{
code: "FG-STAGE",
name: "Finished Goods Staging",
notes: "Outbound-ready finished assemblies.",
},
],
},
},
}); });
} }
} }

View File

@@ -26,6 +26,10 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
return null; return null;
} }
if (!user.isActive) {
return null;
}
const permissionKeys = new Set<PermissionKey>(); const permissionKeys = new Set<PermissionKey>();
const roleNames = user.userRoles.map(({ role }) => { const roleNames = user.userRoles.map(({ role }) => {
for (const rolePermission of role.rolePermissions) { for (const rolePermission of role.rolePermissions) {
@@ -44,4 +48,3 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
permissions: [...permissionKeys], permissions: [...permissionKeys],
}; };
} }

View File

@@ -1,8 +1,15 @@
import puppeteer from "puppeteer"; import puppeteer, { PaperFormat } from "puppeteer";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
export async function renderPdf(html: string) { interface PdfOptions {
format?: PaperFormat;
width?: string;
height?: string;
margin?: { top?: string; right?: string; bottom?: string; left?: string };
}
export async function renderPdf(html: string, options?: PdfOptions) {
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
executablePath: env.PUPPETEER_EXECUTABLE_PATH, executablePath: env.PUPPETEER_EXECUTABLE_PATH,
headless: true, headless: true,
@@ -14,7 +21,10 @@ export async function renderPdf(html: string) {
await page.setContent(html, { waitUntil: "networkidle0" }); await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({ const pdf = await page.pdf({
format: "A4", format: options?.width || options?.height ? undefined : (options?.format || "A4"),
width: options?.width,
height: options?.height,
margin: options?.margin,
printBackground: true, printBackground: true,
preferCSSPageSize: true, preferCSSPageSize: true,
}); });

View File

@@ -1,7 +1,8 @@
import type { SupportLogEntryDto } from "@mrp/shared"; import type { SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto, SupportLogSummaryDto } from "@mrp/shared";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
const SUPPORT_LOG_LIMIT = 200; const SUPPORT_LOG_LIMIT = 500;
const SUPPORT_LOG_RETENTION_DAYS = 14;
const supportLogs: SupportLogEntryDto[] = []; const supportLogs: SupportLogEntryDto[] = [];
@@ -17,12 +18,89 @@ function serializeContext(context?: Record<string, unknown>) {
} }
} }
function getRetentionCutoff(now = new Date()) {
return new Date(now.getTime() - SUPPORT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
}
function pruneSupportLogs(now = new Date()) {
const cutoff = getRetentionCutoff(now).getTime();
const retained = supportLogs.filter((entry) => new Date(entry.createdAt).getTime() >= cutoff);
supportLogs.length = 0;
supportLogs.push(...retained.slice(0, SUPPORT_LOG_LIMIT));
}
function normalizeFilters(filters?: SupportLogFiltersDto): SupportLogFiltersDto {
return {
level: filters?.level,
source: filters?.source?.trim() || undefined,
query: filters?.query?.trim() || undefined,
start: filters?.start,
end: filters?.end,
limit: filters?.limit,
};
}
function filterSupportLogs(filters?: SupportLogFiltersDto) {
pruneSupportLogs();
const normalized = normalizeFilters(filters);
const startMs = normalized.start ? new Date(normalized.start).getTime() : null;
const endMs = normalized.end ? new Date(normalized.end).getTime() : null;
const query = normalized.query?.toLowerCase();
const limit = Math.max(0, Math.min(normalized.limit ?? 100, SUPPORT_LOG_LIMIT));
return supportLogs
.filter((entry) => {
if (normalized.level && entry.level !== normalized.level) {
return false;
}
if (normalized.source && entry.source !== normalized.source) {
return false;
}
const createdAtMs = new Date(entry.createdAt).getTime();
if (startMs != null && createdAtMs < startMs) {
return false;
}
if (endMs != null && createdAtMs > endMs) {
return false;
}
if (!query) {
return true;
}
return [entry.source, entry.message, entry.contextJson].some((value) => value.toLowerCase().includes(query));
})
.slice(0, limit);
}
function buildSupportLogSummary(entries: SupportLogEntryDto[], totalCount: number, availableSources: string[]): SupportLogSummaryDto {
return {
totalCount,
filteredCount: entries.length,
sourceCount: availableSources.length,
retentionDays: SUPPORT_LOG_RETENTION_DAYS,
oldestEntryAt: entries.length > 0 ? entries[entries.length - 1]?.createdAt ?? null : null,
newestEntryAt: entries.length > 0 ? entries[0]?.createdAt ?? null : null,
levelCounts: {
INFO: entries.filter((entry) => entry.level === "INFO").length,
WARN: entries.filter((entry) => entry.level === "WARN").length,
ERROR: entries.filter((entry) => entry.level === "ERROR").length,
},
};
}
export function recordSupportLog(entry: { export function recordSupportLog(entry: {
level: SupportLogEntryDto["level"]; level: SupportLogEntryDto["level"];
source: string; source: string;
message: string; message: string;
context?: Record<string, unknown>; context?: Record<string, unknown>;
}) { }) {
pruneSupportLogs();
supportLogs.unshift({ supportLogs.unshift({
id: randomUUID(), id: randomUUID(),
level: entry.level, level: entry.level,
@@ -37,10 +115,25 @@ export function recordSupportLog(entry: {
} }
} }
export function listSupportLogs(limit = 50) { export function listSupportLogs(filters?: SupportLogFiltersDto): SupportLogListDto {
return supportLogs.slice(0, Math.max(0, limit)); pruneSupportLogs();
const normalized = normalizeFilters(filters);
const availableSources = [...new Set(supportLogs.map((entry) => entry.source))].sort();
const entries = filterSupportLogs(normalized);
return {
entries,
summary: buildSupportLogSummary(entries, supportLogs.length, availableSources),
availableSources,
filters: normalized,
};
} }
export function getSupportLogCount() { export function getSupportLogCount() {
pruneSupportLogs();
return supportLogs.length; return supportLogs.length;
} }
export function getSupportLogRetentionDays() {
return SUPPORT_LOG_RETENTION_DAYS;
}

View File

@@ -6,6 +6,7 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js"; import { requirePermissions } from "../../lib/rbac.js";
import { import {
createAdminRole, createAdminRole,
listAdminAuthSessions,
createAdminUser, createAdminUser,
getBackupGuidance, getBackupGuidance,
getAdminDiagnostics, getAdminDiagnostics,
@@ -14,6 +15,7 @@ import {
listAdminPermissions, listAdminPermissions,
listAdminRoles, listAdminRoles,
listAdminUsers, listAdminUsers,
revokeAdminAuthSession,
updateAdminRole, updateAdminRole,
updateAdminUser, updateAdminUser,
} from "./service.js"; } from "./service.js";
@@ -35,6 +37,15 @@ const userSchema = z.object({
password: z.string().min(8).nullable(), password: z.string().min(8).nullable(),
}); });
const supportLogQuerySchema = z.object({
level: z.enum(["INFO", "WARN", "ERROR"]).optional(),
source: z.string().trim().min(1).optional(),
query: z.string().trim().optional(),
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
});
function getRouteParam(value: unknown) { function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
@@ -48,11 +59,21 @@ adminRouter.get("/backup-guidance", requirePermissions([permissions.adminManage]
}); });
adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => { adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await getSupportSnapshot()); const parsed = supportLogQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Support snapshot filters are invalid.");
}
return ok(response, await getSupportSnapshot(parsed.data));
}); });
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (_request, response) => { adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (request, response) => {
return ok(response, getSupportLogs()); const parsed = supportLogQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Support log filters are invalid.");
}
return ok(response, getSupportLogs(parsed.data));
}); });
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => { adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
@@ -100,6 +121,24 @@ adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (
return ok(response, await listAdminUsers()); return ok(response, await listAdminUsers());
}); });
adminRouter.get("/sessions", requirePermissions([permissions.adminManage]), async (request, response) => {
return ok(response, await listAdminAuthSessions(request.authSessionId));
});
adminRouter.post("/sessions/:sessionId/revoke", requirePermissions([permissions.adminManage]), async (request, response) => {
const sessionId = getRouteParam(request.params.sessionId);
if (!sessionId) {
return fail(response, 400, "INVALID_INPUT", "Session id is invalid.");
}
const result = await revokeAdminAuthSession(sessionId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, { success: true as const });
});
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => { adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = userSchema.safeParse(request.body); const parsed = userSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {

View File

@@ -1,5 +1,6 @@
import type { import type {
AdminDiagnosticsDto, AdminDiagnosticsDto,
AdminAuthSessionDto,
BackupGuidanceDto, BackupGuidanceDto,
AdminPermissionOptionDto, AdminPermissionOptionDto,
AdminRoleDto, AdminRoleDto,
@@ -9,6 +10,8 @@ import type {
SupportSnapshotDto, SupportSnapshotDto,
AuditEventDto, AuditEventDto,
SupportLogEntryDto, SupportLogEntryDto,
SupportLogFiltersDto,
SupportLogListDto,
} from "@mrp/shared"; } from "@mrp/shared";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
@@ -17,7 +20,7 @@ import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js"; import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
import { getLatestStartupReport } from "../../lib/startup-state.js"; import { getLatestStartupReport } from "../../lib/startup-state.js";
import { getSupportLogCount, listSupportLogs } from "../../lib/support-log.js"; import { getSupportLogCount, getSupportLogRetentionDays, listSupportLogs } from "../../lib/support-log.js";
function mapAuditEvent(record: { function mapAuditEvent(record: {
id: string; id: string;
@@ -47,13 +50,15 @@ function mapAuditEvent(record: {
} }
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto { function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
return { ...record };
}
function mapSupportLogList(record: SupportLogListDto): SupportLogListDto {
return { return {
id: record.id, entries: record.entries.map(mapSupportLogEntry),
level: record.level, summary: record.summary,
source: record.source, availableSources: record.availableSources,
message: record.message, filters: record.filters,
contextJson: record.contextJson,
createdAt: record.createdAt,
}; };
} }
@@ -124,6 +129,56 @@ function mapUser(record: {
}; };
} }
function mapAuthSession(
record: {
id: string;
userId: string;
expiresAt: Date;
lastSeenAt: Date;
revokedAt: Date | null;
revokedReason: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: Date;
user: {
email: string;
firstName: string;
lastName: string;
};
revokedBy: {
firstName: string;
lastName: string;
} | null;
},
reviewContext: {
reviewState: "NORMAL" | "REVIEW";
reviewReasons: string[];
},
currentSessionId?: string
): AdminAuthSessionDto {
const now = Date.now();
const status = record.revokedAt ? "REVOKED" : record.expiresAt.getTime() <= now ? "EXPIRED" : "ACTIVE";
return {
id: record.id,
userId: record.userId,
userEmail: record.user.email,
userName: `${record.user.firstName} ${record.user.lastName}`.trim(),
status,
reviewState: reviewContext.reviewState,
reviewReasons: reviewContext.reviewReasons,
isCurrent: record.id === currentSessionId,
createdAt: record.createdAt.toISOString(),
lastSeenAt: record.lastSeenAt.toISOString(),
expiresAt: record.expiresAt.toISOString(),
revokedAt: record.revokedAt?.toISOString() ?? null,
revokedReason: record.revokedReason,
revokedByName: record.revokedBy ? `${record.revokedBy.firstName} ${record.revokedBy.lastName}`.trim() : null,
ipAddress: record.ipAddress,
userAgent: record.userAgent,
};
}
async function validatePermissionKeys(permissionKeys: string[]) { async function validatePermissionKeys(permissionKeys: string[]) {
const uniquePermissionKeys = [...new Set(permissionKeys)]; const uniquePermissionKeys = [...new Set(permissionKeys)];
const permissions = await prisma.permission.findMany({ const permissions = await prisma.permission.findMany({
@@ -338,6 +393,132 @@ export async function listAdminUsers(): Promise<AdminUserDto[]> {
return users.map(mapUser); return users.map(mapUser);
} }
export async function listAdminAuthSessions(currentSessionId?: string | null): Promise<AdminAuthSessionDto[]> {
const sessions = await prisma.authSession.findMany({
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
revokedBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revokedAt: "asc" }, { lastSeenAt: "desc" }, { createdAt: "desc" }],
take: 200,
});
const now = Date.now();
const activeSessionsByUser = new Map<
string,
Array<{
id: string;
ipAddress: string | null;
userAgent: string | null;
lastSeenAt: Date;
}>
>();
for (const session of sessions) {
const isActive = !session.revokedAt && session.expiresAt.getTime() > now;
if (!isActive) {
continue;
}
const existing = activeSessionsByUser.get(session.userId) ?? [];
existing.push({
id: session.id,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
lastSeenAt: session.lastSeenAt,
});
activeSessionsByUser.set(session.userId, existing);
}
return sessions.map((session) => {
const reviewReasons: string[] = [];
const activeUserSessions = activeSessionsByUser.get(session.userId) ?? [];
const isActive = !session.revokedAt && session.expiresAt.getTime() > now;
const staleThresholdMs = 7 * 24 * 60 * 60 * 1000;
if (isActive && activeUserSessions.length > 1) {
reviewReasons.push("Multiple active sessions");
}
if (isActive) {
const distinctIps = new Set(activeUserSessions.map((entry) => entry.ipAddress).filter(Boolean));
if (distinctIps.size > 1) {
reviewReasons.push("Multiple active IP addresses");
}
if (now - session.lastSeenAt.getTime() > staleThresholdMs) {
reviewReasons.push("Stale active session");
}
}
return mapAuthSession(
session,
{
reviewState: reviewReasons.length > 0 ? "REVIEW" : "NORMAL",
reviewReasons,
},
currentSessionId ?? undefined
);
});
}
export async function revokeAdminAuthSession(sessionId: string, actorId?: string | null) {
const existingSession = await prisma.authSession.findUnique({
where: { id: sessionId },
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!existingSession) {
return { ok: false as const, reason: "Session was not found." };
}
if (existingSession.revokedAt) {
return { ok: false as const, reason: "Session is already revoked." };
}
await prisma.authSession.update({
where: { id: sessionId },
data: {
revokedAt: new Date(),
revokedById: actorId ?? null,
revokedReason: "Revoked by administrator.",
},
});
await logAuditEvent({
actorId,
entityType: "auth-session",
entityId: existingSession.id,
action: "revoked",
summary: `Revoked session for ${existingSession.user.email}.`,
metadata: {
userId: existingSession.userId,
userEmail: existingSession.user.email,
},
});
return { ok: true as const };
}
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) { export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
if (!payload.password || payload.password.trim().length < 8) { if (!payload.password || payload.password.trim().length < 8) {
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." }; return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
@@ -480,11 +661,14 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> { export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport(); const startupReport = getLatestStartupReport();
const recentSupportLogs = listSupportLogs(50); const recentSupportLogs = listSupportLogs({ limit: 50 });
const now = new Date();
const reviewSessions = await listAdminAuthSessions();
const [ const [
companyProfile, companyProfile,
userCount, userCount,
activeUserCount, activeUserCount,
activeSessionCount,
roleCount, roleCount,
permissionCount, permissionCount,
customerCount, customerCount,
@@ -504,6 +688,14 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }), prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
prisma.user.count(), prisma.user.count(),
prisma.user.count({ where: { isActive: true } }), prisma.user.count({ where: { isActive: true } }),
prisma.authSession.count({
where: {
revokedAt: null,
expiresAt: {
gt: now,
},
},
}),
prisma.role.count(), prisma.role.count(),
prisma.permission.count(), prisma.permission.count(),
prisma.customer.count(), prisma.customer.count(),
@@ -542,6 +734,8 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
companyProfilePresent: Boolean(companyProfile), companyProfilePresent: Boolean(companyProfile),
userCount, userCount,
activeUserCount, activeUserCount,
activeSessionCount,
reviewSessionCount: reviewSessions.filter((session) => session.reviewState === "REVIEW").length,
roleCount, roleCount,
permissionCount, permissionCount,
customerCount, customerCount,
@@ -558,7 +752,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
supportLogCount: getSupportLogCount(), supportLogCount: getSupportLogCount(),
startup: startupReport, startup: startupReport,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent), recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
recentSupportLogs: recentSupportLogs.map(mapSupportLogEntry), recentSupportLogs: recentSupportLogs.entries.map(mapSupportLogEntry),
}; };
} }
@@ -673,9 +867,10 @@ export function getBackupGuidance(): BackupGuidanceDto {
}; };
} }
export async function getSupportSnapshot(): Promise<SupportSnapshotDto> { export async function getSupportSnapshot(filters?: SupportLogFiltersDto): Promise<SupportSnapshotDto> {
const diagnostics = await getAdminDiagnostics(); const diagnostics = await getAdminDiagnostics();
const backupGuidance = getBackupGuidance(); const backupGuidance = getBackupGuidance();
const supportLogs = listSupportLogs({ limit: 200, ...filters });
const [users, roles] = await Promise.all([ const [users, roles] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where: { isActive: true }, where: { isActive: true },
@@ -692,10 +887,16 @@ export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
roleCount: roles, roleCount: roles,
activeUserEmails: users.map((user) => user.email), activeUserEmails: users.map((user) => user.email),
backupGuidance, backupGuidance,
recentSupportLogs: diagnostics.recentSupportLogs, supportLogs: mapSupportLogList(supportLogs),
}; };
} }
export function getSupportLogs() { export function getSupportLogs(filters?: SupportLogFiltersDto) {
return listSupportLogs(100).map(mapSupportLogEntry); return mapSupportLogList(listSupportLogs(filters));
}
export function getSupportLogRetentionPolicy() {
return {
retentionDays: getSupportLogRetentionDays(),
};
} }

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { fail, ok } from "../../lib/http.js"; import { fail, ok } from "../../lib/http.js";
import { requireAuth } from "../../lib/rbac.js"; import { requireAuth } from "../../lib/rbac.js";
import { login } from "./service.js"; import { login, logout } from "./service.js";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -18,7 +18,10 @@ authRouter.post("/login", async (request, response) => {
return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password."); return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password.");
} }
const result = await login(parsed.data); const result = await login(parsed.data, {
ipAddress: request.ip,
userAgent: request.header("user-agent"),
});
if (!result) { if (!result) {
return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect."); return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect.");
} }
@@ -28,3 +31,11 @@ authRouter.post("/login", async (request, response) => {
authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser)); authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser));
authRouter.post("/logout", requireAuth, async (request, response) => {
if (!request.authSessionId || !request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
await logout(request.authSessionId, request.authUser.id);
return ok(response, { success: true as const });
});

View File

@@ -1,11 +1,18 @@
import type { LoginRequest, LoginResponse } from "@mrp/shared"; import type { LoginRequest, LoginResponse } from "@mrp/shared";
import { signToken } from "../../lib/auth.js"; import { signToken } from "../../lib/auth.js";
import { createAuthSession, revokeAuthSession } from "../../lib/auth-sessions.js";
import { getCurrentUserById } from "../../lib/current-user.js"; import { getCurrentUserById } from "../../lib/current-user.js";
import { verifyPassword } from "../../lib/password.js"; import { verifyPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
export async function login(payload: LoginRequest): Promise<LoginResponse | null> { export async function login(
payload: LoginRequest,
context?: {
ipAddress?: string | null;
userAgent?: string | null;
}
): Promise<LoginResponse | null> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: payload.email.toLowerCase() }, where: { email: payload.email.toLowerCase() },
}); });
@@ -23,9 +30,21 @@ export async function login(payload: LoginRequest): Promise<LoginResponse | null
return null; return null;
} }
const session = await createAuthSession({
userId: user.id,
ipAddress: context?.ipAddress ?? null,
userAgent: context?.userAgent ?? null,
});
return { return {
token: signToken(authUser), token: signToken(authUser, session.id),
user: authUser, user: authUser,
}; };
} }
export async function logout(sessionId: string, actorId?: string | null) {
await revokeAuthSession(sessionId, {
revokedById: actorId ?? null,
reason: actorId ? "User signed out." : "Session signed out.",
});
}

View File

@@ -152,29 +152,40 @@ function buildShippingLabelPdf(options: {
<html> <html>
<head> <head>
<style> <style>
@page { size: 4in 6in; margin: 8mm; } @page { size: 4in 6in; margin: 0; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; } *, *::before, *::after { box-sizing: border-box; }
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: calc(6in - 16mm); box-sizing: border-box; } html, body { width: 4in; min-width: 4in; max-width: 4in; height: 6in; min-height: 6in; max-height: 6in; margin: 0; padding: 0; overflow: hidden; background: white; }
.row { display: flex; justify-content: space-between; gap: 12px; } body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 10px; line-height: 1.2; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.page { width: 4in; height: 6in; padding: 0.14in; overflow: hidden; page-break-after: avoid; break-after: avoid-page; }
.label { width: 100%; height: 100%; border: 2px solid #111827; border-radius: 10px; padding: 0.11in; display: flex; flex-direction: column; gap: 0.09in; overflow: hidden; }
.row { display: flex; justify-content: space-between; gap: 0.09in; }
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; } .muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; } .brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 0.09in; }
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; } .brand-row { align-items: flex-start; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; } .brand-company { flex: 1; min-width: 0; padding-right: 0.06in; }
.stack { display: flex; flex-direction: column; gap: 4px; } .brand h1 { margin: 0; font-size: 16px; line-height: 1.05; color: ${company.theme.primaryColor}; overflow-wrap: anywhere; }
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; } .shipment-number { width: 1.25in; flex: 0 0 1.25in; text-align: right; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 0.08in; min-width: 0; }
.stack { display: flex; flex-direction: column; gap: 3px; }
.barcode { border: 2px solid #111827; border-radius: 8px; padding: 0.08in; text-align: center; font-family: monospace; font-size: 16px; line-height: 1; letter-spacing: 0.15em; }
.strong { font-weight: 700; } .strong { font-weight: 700; }
.big { font-size: 16px; font-weight: 700; } .big { font-size: 15px; line-height: 1.05; font-weight: 700; }
.footer { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
.reference-text { margin-top: 6px; overflow-wrap: anywhere; word-break: break-word; }
.block > div[style="margin-top:6px;"] { overflow-wrap: anywhere; word-break: break-word; }
div[style="text-align:center; font-size:10px; color:#4b5563;"] { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
</style> </style>
</head> </head>
<body> <body>
<div class="label"> <div class="page">
<div class="label">
<div class="brand"> <div class="brand">
<div class="row"> <div class="row brand-row">
<div> <div class="brand-company">
<div class="muted">From</div> <div class="muted">From</div>
<h1>${escapeHtml(company.companyName)}</h1> <h1>${escapeHtml(company.companyName)}</h1>
</div> </div>
<div style="text-align:right;"> <div class="shipment-number">
<div class="muted">Shipment</div> <div class="muted">Shipment</div>
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div> <div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
</div> </div>
@@ -217,7 +228,7 @@ function buildShippingLabelPdf(options: {
</div> </div>
</body> </body>
</html> </html>
`); `, { width: "4in", height: "6in", margin: { top: "0", right: "0", bottom: "0", left: "0" } });
} }
function buildBillOfLadingPdf(options: { function buildBillOfLadingPdf(options: {

View File

@@ -0,0 +1,104 @@
import { permissions } from "@mrp/shared";
import { capexCategories, capexStatuses, financePaymentMethods, financePaymentTypes } from "@mrp/shared/dist/finance/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { createCapexEntry, createCustomerPayment, getFinanceDashboard, updateCapexEntry, updateFinanceProfile } from "./service.js";
const financeProfileSchema = z.object({
currencyCode: z.string().trim().min(3).max(8),
standardLaborRatePerHour: z.number().nonnegative(),
overheadRatePerHour: z.number().nonnegative(),
});
const financePaymentSchema = z.object({
salesOrderId: z.string().trim().min(1),
paymentType: z.enum(financePaymentTypes),
paymentMethod: z.enum(financePaymentMethods),
paymentDate: z.string().datetime(),
amount: z.number().positive(),
reference: z.string(),
notes: z.string(),
});
const capexSchema = z.object({
title: z.string().trim().min(1),
category: z.enum(capexCategories),
status: z.enum(capexStatuses),
vendorId: z.string().trim().min(1).nullable(),
purchaseOrderId: z.string().trim().min(1).nullable(),
plannedAmount: z.number().nonnegative(),
actualAmount: z.number().nonnegative(),
requestDate: z.string().datetime(),
targetInServiceDate: z.string().datetime().nullable(),
purchasedAt: z.string().datetime().nullable(),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const financeRouter = Router();
financeRouter.get("/overview", requirePermissions([permissions.financeRead]), async (_request, response) => {
return ok(response, await getFinanceDashboard());
});
financeRouter.put("/profile", requirePermissions([permissions.financeWrite]), async (request, response) => {
const parsed = financeProfileSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Finance profile payload is invalid.");
}
return ok(response, await updateFinanceProfile(parsed.data, request.authUser?.id));
});
financeRouter.post("/payments", requirePermissions([permissions.financeWrite]), async (request, response) => {
const parsed = financePaymentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Finance payment payload is invalid.");
}
const result = await createCustomerPayment(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.payment, 201);
});
financeRouter.post("/capex", requirePermissions([permissions.financeWrite]), async (request, response) => {
const parsed = capexSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CapEx payload is invalid.");
}
const result = await createCapexEntry(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.capex, 201);
});
financeRouter.put("/capex/:capexId", requirePermissions([permissions.financeWrite]), async (request, response) => {
const capexId = getRouteParam(request.params.capexId);
if (!capexId) {
return fail(response, 400, "INVALID_INPUT", "CapEx id is invalid.");
}
const parsed = capexSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CapEx payload is invalid.");
}
const result = await updateCapexEntry(capexId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.capex);
});

View File

@@ -0,0 +1,619 @@
import type {
FinanceCapexDto,
FinanceCapexInput,
FinanceCustomerPaymentDto,
FinanceCustomerPaymentInput,
FinanceDashboardDto,
FinanceProfileDto,
FinanceProfileInput,
FinanceSalesOrderLedgerDto,
FinanceSummaryDto,
} from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
function iso(value: Date | null) {
return value ? value.toISOString() : null;
}
function mapProfile(record: {
id: string;
currencyCode: string;
standardLaborRatePerHour: number;
overheadRatePerHour: number;
createdAt: Date;
updatedAt: Date;
}): FinanceProfileDto {
return {
id: record.id,
currencyCode: record.currencyCode,
standardLaborRatePerHour: record.standardLaborRatePerHour,
overheadRatePerHour: record.overheadRatePerHour,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function mapPayment(record: {
id: string;
paymentType: string;
paymentMethod: string;
paymentDate: Date;
amount: number;
reference: string;
notes: string;
createdAt: Date;
salesOrder: {
id: string;
documentNumber: string;
customer: {
id: string;
name: string;
};
};
createdBy: {
firstName: string;
lastName: string;
} | null;
}): FinanceCustomerPaymentDto {
return {
id: record.id,
salesOrderId: record.salesOrder.id,
salesOrderNumber: record.salesOrder.documentNumber,
customerId: record.salesOrder.customer.id,
customerName: record.salesOrder.customer.name,
paymentType: record.paymentType as FinanceCustomerPaymentDto["paymentType"],
paymentMethod: record.paymentMethod as FinanceCustomerPaymentDto["paymentMethod"],
paymentDate: record.paymentDate.toISOString(),
amount: record.amount,
reference: record.reference,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
createdByName: record.createdBy ? `${record.createdBy.firstName} ${record.createdBy.lastName}`.trim() : "System",
};
}
function mapCapex(record: {
id: string;
title: string;
category: string;
status: string;
plannedAmount: number;
actualAmount: number;
requestDate: Date;
targetInServiceDate: Date | null;
purchasedAt: Date | null;
notes: string;
createdAt: Date;
updatedAt: Date;
vendor: {
id: string;
name: string;
} | null;
purchaseOrder: {
id: string;
documentNumber: string;
} | null;
}): FinanceCapexDto {
return {
id: record.id,
title: record.title,
category: record.category as FinanceCapexDto["category"],
status: record.status as FinanceCapexDto["status"],
vendorId: record.vendor?.id ?? null,
vendorName: record.vendor?.name ?? null,
purchaseOrderId: record.purchaseOrder?.id ?? null,
purchaseOrderNumber: record.purchaseOrder?.documentNumber ?? null,
plannedAmount: record.plannedAmount,
actualAmount: record.actualAmount,
requestDate: record.requestDate.toISOString(),
targetInServiceDate: iso(record.targetInServiceDate),
purchasedAt: iso(record.purchasedAt),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function getOrCreateProfile() {
const existing = await prisma.financeProfile.findFirst({
orderBy: { createdAt: "asc" },
});
if (existing) {
return existing;
}
return prisma.financeProfile.create({
data: {},
});
}
async function computeWorkOrderCostSnapshot(
workOrder: {
id: string;
materialIssues: Array<{
quantity: number;
componentItem: {
defaultCost: number | null;
};
}>;
operations: Array<{
laborEntries: Array<{
minutes: number;
}>;
}>;
},
profile: {
standardLaborRatePerHour: number;
overheadRatePerHour: number;
}
) {
const materialCost = workOrder.materialIssues.reduce((sum, issue) => sum + issue.quantity * (issue.componentItem.defaultCost ?? 0), 0);
const laborMinutes = workOrder.operations.reduce(
(sum, operation) => sum + operation.laborEntries.reduce((entrySum, entry) => entrySum + entry.minutes, 0),
0
);
const laborHours = laborMinutes / 60;
const laborCost = laborHours * profile.standardLaborRatePerHour;
const overheadCost = laborHours * profile.overheadRatePerHour;
const totalCost = materialCost + laborCost + overheadCost;
await prisma.financeManufacturingCostSnapshot.upsert({
where: { workOrderId: workOrder.id },
update: {
materialCost,
laborCost,
overheadCost,
totalCost,
materialIssueCount: workOrder.materialIssues.length,
laborEntryCount: workOrder.operations.reduce((sum, operation) => sum + operation.laborEntries.length, 0),
calculatedAt: new Date(),
},
create: {
workOrderId: workOrder.id,
materialCost,
laborCost,
overheadCost,
totalCost,
materialIssueCount: workOrder.materialIssues.length,
laborEntryCount: workOrder.operations.reduce((sum, operation) => sum + operation.laborEntries.length, 0),
calculatedAt: new Date(),
},
});
return {
materialCost,
laborCost,
overheadCost,
totalCost,
};
}
export async function getFinanceDashboard(): Promise<FinanceDashboardDto> {
const profile = await getOrCreateProfile();
const [orders, payments, capex] = await Promise.all([
prisma.salesOrder.findMany({
include: {
customer: {
select: {
id: true,
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
customerPayments: {
select: {
amount: true,
},
},
purchaseOrderLines: {
include: {
purchaseOrder: {
include: {
receipts: {
include: {
lines: true,
},
},
},
},
},
},
workOrders: {
include: {
materialIssues: {
include: {
componentItem: {
select: {
defaultCost: true,
},
},
},
},
operations: {
include: {
laborEntries: {
select: {
minutes: true,
},
},
},
},
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
}),
prisma.financeCustomerPayment.findMany({
include: {
salesOrder: {
include: {
customer: {
select: {
id: true,
name: true,
},
},
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }],
take: 40,
}),
prisma.capexEntry.findMany({
include: {
vendor: {
select: {
id: true,
name: true,
},
},
purchaseOrder: {
select: {
id: true,
documentNumber: true,
},
},
},
orderBy: [{ requestDate: "desc" }, { createdAt: "desc" }],
}),
]);
const salesOrderLedgers: FinanceSalesOrderLedgerDto[] = [];
for (const order of orders) {
const revenueTotal = order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const paymentsReceived = order.customerPayments.reduce((sum, payment) => sum + payment.amount, 0);
const linkedPurchaseCommitted = order.purchaseOrderLines.reduce((sum, line) => sum + line.quantity * line.unitCost, 0);
const linkedPurchaseReceivedValue = order.purchaseOrderLines.reduce((sum, line) => {
const receivedQuantity = line.purchaseOrder.receipts.reduce((receiptSum, receipt) => {
const matchingQuantity = receipt.lines
.filter((receiptLine) => receiptLine.purchaseOrderLineId === line.id)
.reduce((lineSum, receiptLine) => lineSum + receiptLine.quantity, 0);
return receiptSum + matchingQuantity;
}, 0);
return sum + receivedQuantity * line.unitCost;
}, 0);
let manufacturingMaterialCost = 0;
let manufacturingLaborCost = 0;
let manufacturingOverheadCost = 0;
for (const workOrder of order.workOrders) {
const snapshot = await computeWorkOrderCostSnapshot(workOrder, profile);
manufacturingMaterialCost += snapshot.materialCost;
manufacturingLaborCost += snapshot.laborCost;
manufacturingOverheadCost += snapshot.overheadCost;
}
const manufacturingTotalCost = manufacturingMaterialCost + manufacturingLaborCost + manufacturingOverheadCost;
const totalRecognizedSpend = linkedPurchaseReceivedValue + manufacturingTotalCost;
const grossMarginEstimate = revenueTotal - totalRecognizedSpend;
const grossMarginPercent = revenueTotal > 0 ? (grossMarginEstimate / revenueTotal) * 100 : 0;
const accountsReceivableOpen = Math.max(revenueTotal - paymentsReceived, 0);
const paymentCoveragePercent = totalRecognizedSpend > 0 ? (paymentsReceived / totalRecognizedSpend) * 100 : 0;
salesOrderLedgers.push({
salesOrderId: order.id,
salesOrderNumber: order.documentNumber,
customerId: order.customer.id,
customerName: order.customer.name,
status: order.status,
issueDate: order.issueDate.toISOString(),
revenueTotal,
paymentsReceived,
accountsReceivableOpen,
linkedPurchaseCommitted,
linkedPurchaseReceivedValue,
manufacturingMaterialCost,
manufacturingLaborCost,
manufacturingOverheadCost,
manufacturingTotalCost,
totalRecognizedSpend,
grossMarginEstimate,
grossMarginPercent,
paymentCoveragePercent,
linkedPurchaseOrderCount: new Set(order.purchaseOrderLines.map((line) => line.purchaseOrderId)).size,
linkedWorkOrderCount: order.workOrders.length,
});
}
const summary: FinanceSummaryDto = {
bookedRevenue: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.revenueTotal, 0),
paymentsReceived: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.paymentsReceived, 0),
accountsReceivableOpen: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.accountsReceivableOpen, 0),
linkedPurchaseCommitted: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.linkedPurchaseCommitted, 0),
linkedPurchaseReceivedValue: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.linkedPurchaseReceivedValue, 0),
manufacturingMaterialCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingMaterialCost, 0),
manufacturingLaborCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingLaborCost, 0),
manufacturingOverheadCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingOverheadCost, 0),
manufacturingTotalCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingTotalCost, 0),
capexPlanned: capex.reduce((sum, entry) => sum + entry.plannedAmount, 0),
capexActual: capex.reduce((sum, entry) => sum + entry.actualAmount, 0),
grossMarginEstimate: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.grossMarginEstimate, 0),
};
return {
generatedAt: new Date().toISOString(),
profile: mapProfile(profile),
summary,
salesOrderLedgers,
payments: payments.map(mapPayment),
capex: capex.map(mapCapex),
};
}
export async function updateFinanceProfile(payload: FinanceProfileInput, actorId?: string | null) {
const profile = await getOrCreateProfile();
const updated = await prisma.financeProfile.update({
where: { id: profile.id },
data: {
currencyCode: payload.currencyCode.trim().toUpperCase(),
standardLaborRatePerHour: payload.standardLaborRatePerHour,
overheadRatePerHour: payload.overheadRatePerHour,
},
});
await logAuditEvent({
actorId,
entityType: "finance-profile",
entityId: updated.id,
action: "updated",
summary: "Updated finance costing assumptions.",
metadata: {
currencyCode: updated.currencyCode,
standardLaborRatePerHour: updated.standardLaborRatePerHour,
overheadRatePerHour: updated.overheadRatePerHour,
},
});
return mapProfile(updated);
}
export async function createCustomerPayment(payload: FinanceCustomerPaymentInput, actorId?: string | null) {
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
include: {
customer: {
select: {
name: true,
},
},
},
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
const payment = await prisma.financeCustomerPayment.create({
data: {
salesOrderId: payload.salesOrderId,
paymentType: payload.paymentType,
paymentMethod: payload.paymentMethod,
paymentDate: new Date(payload.paymentDate),
amount: payload.amount,
reference: payload.reference.trim(),
notes: payload.notes,
createdById: actorId ?? null,
},
include: {
salesOrder: {
include: {
customer: {
select: {
id: true,
name: true,
},
},
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "finance-payment",
entityId: payment.id,
action: "created",
summary: `Posted customer payment against ${order.documentNumber}.`,
metadata: {
salesOrderId: order.id,
salesOrderNumber: order.documentNumber,
amount: payment.amount,
paymentType: payment.paymentType,
paymentMethod: payment.paymentMethod,
},
});
return { ok: true as const, payment: mapPayment(payment) };
}
export async function createCapexEntry(payload: FinanceCapexInput, actorId?: string | null) {
if (payload.vendorId) {
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Selected vendor was not found." };
}
}
if (payload.purchaseOrderId) {
const purchaseOrder = await prisma.purchaseOrder.findUnique({
where: { id: payload.purchaseOrderId },
select: { id: true },
});
if (!purchaseOrder) {
return { ok: false as const, reason: "Selected purchase order was not found." };
}
}
const created = await prisma.capexEntry.create({
data: {
title: payload.title.trim(),
category: payload.category,
status: payload.status,
vendorId: payload.vendorId,
purchaseOrderId: payload.purchaseOrderId,
plannedAmount: payload.plannedAmount,
actualAmount: payload.actualAmount,
requestDate: new Date(payload.requestDate),
targetInServiceDate: payload.targetInServiceDate ? new Date(payload.targetInServiceDate) : null,
purchasedAt: payload.purchasedAt ? new Date(payload.purchasedAt) : null,
notes: payload.notes,
},
include: {
vendor: {
select: {
id: true,
name: true,
},
},
purchaseOrder: {
select: {
id: true,
documentNumber: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "capex-entry",
entityId: created.id,
action: "created",
summary: `Created CapEx entry ${created.title}.`,
metadata: {
title: created.title,
category: created.category,
status: created.status,
plannedAmount: created.plannedAmount,
actualAmount: created.actualAmount,
purchaseOrderId: created.purchaseOrder?.id ?? null,
},
});
return { ok: true as const, capex: mapCapex(created) };
}
export async function updateCapexEntry(capexId: string, payload: FinanceCapexInput, actorId?: string | null) {
const existing = await prisma.capexEntry.findUnique({
where: { id: capexId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "CapEx entry was not found." };
}
if (payload.vendorId) {
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Selected vendor was not found." };
}
}
if (payload.purchaseOrderId) {
const purchaseOrder = await prisma.purchaseOrder.findUnique({
where: { id: payload.purchaseOrderId },
select: { id: true },
});
if (!purchaseOrder) {
return { ok: false as const, reason: "Selected purchase order was not found." };
}
}
const updated = await prisma.capexEntry.update({
where: { id: capexId },
data: {
title: payload.title.trim(),
category: payload.category,
status: payload.status,
vendorId: payload.vendorId,
purchaseOrderId: payload.purchaseOrderId,
plannedAmount: payload.plannedAmount,
actualAmount: payload.actualAmount,
requestDate: new Date(payload.requestDate),
targetInServiceDate: payload.targetInServiceDate ? new Date(payload.targetInServiceDate) : null,
purchasedAt: payload.purchasedAt ? new Date(payload.purchasedAt) : null,
notes: payload.notes,
},
include: {
vendor: {
select: {
id: true,
name: true,
},
},
purchaseOrder: {
select: {
id: true,
documentNumber: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "capex-entry",
entityId: updated.id,
action: "updated",
summary: `Updated CapEx entry ${updated.title}.`,
metadata: {
title: updated.title,
category: updated.category,
status: updated.status,
plannedAmount: updated.plannedAmount,
actualAmount: updated.actualAmount,
purchaseOrderId: updated.purchaseOrder?.id ?? null,
},
});
return { ok: true as const, capex: mapCapex(updated) };
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,16 @@ import { requirePermissions } from "../../lib/rbac.js";
import { import {
createInventoryItem, createInventoryItem,
createInventoryReservation, createInventoryReservation,
createInventorySkuFamily,
createInventorySkuNode,
createInventoryTransfer, createInventoryTransfer,
createInventoryTransaction, createInventoryTransaction,
createWarehouse, createWarehouse,
getInventoryItemById, getInventoryItemById,
listInventorySkuCatalog,
listInventorySkuFamilies,
listInventorySkuNodeOptions,
previewInventorySku,
getWarehouseById, getWarehouseById,
listInventoryItemOptions, listInventoryItemOptions,
listInventoryItems, listInventoryItems,
@@ -40,6 +46,12 @@ const operationSchema = z.object({
const inventoryItemSchema = z.object({ const inventoryItemSchema = z.object({
sku: z.string().trim().min(1).max(64), sku: z.string().trim().min(1).max(64),
skuBuilder: z
.object({
familyId: z.string().trim().min(1),
nodeId: z.string().trim().min(1).nullable(),
})
.nullable(),
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
description: z.string(), description: z.string(),
type: z.enum(inventoryItemTypes), type: z.enum(inventoryItemTypes),
@@ -99,6 +111,34 @@ const warehouseSchema = z.object({
locations: z.array(warehouseLocationSchema), locations: z.array(warehouseLocationSchema),
}); });
const skuFamilySchema = z.object({
code: z.string().trim().min(2).max(12),
sequenceCode: z.string().trim().min(2).max(2),
name: z.string().trim().min(1).max(160),
description: z.string(),
isActive: z.boolean(),
});
const skuNodeSchema = z.object({
familyId: z.string().trim().min(1),
parentNodeId: z.string().trim().min(1).nullable(),
code: z.string().trim().min(1).max(32),
label: z.string().trim().min(1).max(160),
description: z.string(),
sortOrder: z.number().int().nonnegative(),
isActive: z.boolean(),
});
const skuPreviewQuerySchema = z.object({
familyId: z.string().trim().min(1),
nodeId: z.string().trim().min(1).nullable().optional(),
});
const skuNodeOptionsQuerySchema = z.object({
familyId: z.string().trim().min(1),
parentNodeId: z.string().trim().min(1).nullable().optional(),
});
function getRouteParam(value: unknown) { function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
@@ -125,6 +165,46 @@ inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryR
return ok(response, await listInventoryItemOptions()); return ok(response, await listInventoryItemOptions());
}); });
inventoryRouter.get("/sku/families", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listInventorySkuFamilies());
});
inventoryRouter.get("/sku/catalog", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listInventorySkuCatalog());
});
inventoryRouter.get("/sku/nodes", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const parsed = skuNodeOptionsQuerySchema.safeParse({
familyId: request.query.familyId,
parentNodeId: request.query.parentNodeId ?? null,
});
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU node filters are invalid.");
}
return ok(response, await listInventorySkuNodeOptions(parsed.data.familyId, parsed.data.parentNodeId ?? null));
});
inventoryRouter.get("/sku/preview", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const parsed = skuPreviewQuerySchema.safeParse({
familyId: request.query.familyId,
nodeId: request.query.nodeId ?? null,
});
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
}
const preview = await previewInventorySku({
familyId: parsed.data.familyId,
nodeId: parsed.data.nodeId ?? null,
});
if (!preview) {
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
}
return ok(response, preview);
});
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => { inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouseLocationOptions()); return ok(response, await listWarehouseLocationOptions());
}); });
@@ -157,6 +237,34 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
return ok(response, item, 201); return ok(response, item, 201);
}); });
inventoryRouter.post("/sku/families", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = skuFamilySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
}
const family = await createInventorySkuFamily(parsed.data);
if (!family) {
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
}
return ok(response, family, 201);
});
inventoryRouter.post("/sku/nodes", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = skuNodeSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
}
const node = await createInventorySkuNode(parsed.data);
if (!node) {
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
}
return ok(response, node, 201);
});
inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => { inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId); const itemId = getRouteParam(request.params.itemId);
if (!itemId) { if (!itemId) {

View File

@@ -4,6 +4,14 @@ import type {
InventoryItemDetailDto, InventoryItemDetailDto,
InventoryItemInput, InventoryItemInput,
InventoryItemOperationDto, InventoryItemOperationDto,
InventorySkuBuilderInput,
InventorySkuBuilderPreviewDto,
InventorySkuBuilderSelectionDto,
InventorySkuCatalogTreeDto,
InventorySkuFamilyDto,
InventorySkuFamilyInput,
InventorySkuNodeDto,
InventorySkuNodeInput,
InventoryReservationDto, InventoryReservationDto,
InventoryReservationInput, InventoryReservationInput,
InventoryReservationStatus, InventoryReservationStatus,
@@ -58,6 +66,9 @@ type OperationRecord = {
type InventoryDetailRecord = { type InventoryDetailRecord = {
id: string; id: string;
sku: string; sku: string;
skuFamilyId: string | null;
skuNodeId: string | null;
skuSequenceNumber: number | null;
name: string; name: string;
description: string; description: string;
type: string; type: string;
@@ -74,6 +85,20 @@ type InventoryDetailRecord = {
notes: string; notes: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
skuFamily: {
id: string;
code: string;
sequenceCode: string;
name: string;
} | null;
skuNode: {
id: string;
code: string;
label: string;
path: string;
level: number;
parentNodeId: string | null;
} | null;
bomLines: BomLineRecord[]; bomLines: BomLineRecord[];
operations: OperationRecord[]; operations: OperationRecord[];
inventoryTransactions: InventoryTransactionRecord[]; inventoryTransactions: InventoryTransactionRecord[];
@@ -176,6 +201,36 @@ type InventoryTransferRecord = {
} | null; } | null;
}; };
type InventorySkuFamilyRecord = {
id: string;
code: string;
sequenceCode: string;
name: string;
description: string;
nextSequenceNumber: number;
isActive: boolean;
_count: {
nodes: number;
items: number;
};
};
type InventorySkuNodeRecord = {
id: string;
familyId: string;
parentNodeId: string | null;
code: string;
label: string;
description: string;
path: string;
level: number;
sortOrder: number;
isActive: boolean;
_count: {
childNodes: number;
};
};
function mapBomLine(record: BomLineRecord): InventoryBomLineDto { function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
return { return {
id: record.id, id: record.id,
@@ -280,6 +335,44 @@ function mapTransfer(record: InventoryTransferRecord): InventoryTransferDto {
}; };
} }
function mapSkuFamily(record: InventorySkuFamilyRecord): InventorySkuFamilyDto {
return {
id: record.id,
code: record.code,
sequenceCode: record.sequenceCode,
name: record.name,
description: record.description,
nextSequenceNumber: record.nextSequenceNumber,
isActive: record.isActive,
childNodeCount: record._count.nodes,
itemCount: record._count.items,
};
}
function mapSkuNode(record: InventorySkuNodeRecord): InventorySkuNodeDto {
return {
id: record.id,
familyId: record.familyId,
parentNodeId: record.parentNodeId,
code: record.code,
label: record.label,
description: record.description,
path: record.path,
level: record.level,
sortOrder: record.sortOrder,
isActive: record.isActive,
childCount: record._count.childNodes,
};
}
function formatSkuSequence(sequenceNumber: number) {
return sequenceNumber.toString().padStart(4, "0");
}
function formatGeneratedSku(sequenceCode: string, segments: string[], sequenceNumber: number) {
return [...segments, `${sequenceCode}${formatSkuSequence(sequenceNumber)}`].join("-");
}
function buildStockBalances(transactions: InventoryTransactionRecord[], reservations: InventoryReservationRecord[]): InventoryStockBalanceDto[] { function buildStockBalances(transactions: InventoryTransactionRecord[], reservations: InventoryReservationRecord[]): InventoryStockBalanceDto[] {
const grouped = new Map<string, InventoryStockBalanceDto>(); const grouped = new Map<string, InventoryStockBalanceDto>();
@@ -341,6 +434,23 @@ function buildStockBalances(transactions: InventoryTransactionRecord[], reservat
); );
} }
function calculateSummaryQuantities(
transactions: Array<{ transactionType: string; quantity: number }>,
reservations: Array<{ status: string; quantity: number }>
) {
const onHandQuantity = transactions.reduce((sum, transaction) => {
return sum + getSignedQuantity(transaction.transactionType as InventoryTransactionType, transaction.quantity);
}, 0);
const reservedQuantity = reservations.reduce((sum, reservation) => {
return reservation.status === "ACTIVE" ? sum + reservation.quantity : sum;
}, 0);
return {
onHandQuantity,
availableQuantity: onHandQuantity - reservedQuantity,
};
}
function mapSummary(record: { function mapSummary(record: {
id: string; id: string;
sku: string; sku: string;
@@ -351,8 +461,12 @@ function mapSummary(record: {
isSellable: boolean; isSellable: boolean;
isPurchasable: boolean; isPurchasable: boolean;
updatedAt: Date; updatedAt: Date;
inventoryTransactions?: Array<{ transactionType: string; quantity: number }>;
reservations?: Array<{ status: string; quantity: number }>;
_count: { bomLines: number }; _count: { bomLines: number };
}): InventoryItemSummaryDto { }): InventoryItemSummaryDto {
const quantities = calculateSummaryQuantities(record.inventoryTransactions ?? [], record.reservations ?? []);
return { return {
id: record.id, id: record.id,
sku: record.sku, sku: record.sku,
@@ -362,12 +476,14 @@ function mapSummary(record: {
unitOfMeasure: record.unitOfMeasure as InventoryUnitOfMeasure, unitOfMeasure: record.unitOfMeasure as InventoryUnitOfMeasure,
isSellable: record.isSellable, isSellable: record.isSellable,
isPurchasable: record.isPurchasable, isPurchasable: record.isPurchasable,
onHandQuantity: quantities.onHandQuantity,
availableQuantity: quantities.availableQuantity,
bomLineCount: record._count.bomLines, bomLineCount: record._count.bomLines,
updatedAt: record.updatedAt.toISOString(), updatedAt: record.updatedAt.toISOString(),
}; };
} }
function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto { function mapDetail(record: InventoryDetailRecord, skuBuilder: InventorySkuBuilderSelectionDto | null): InventoryItemDetailDto {
const recentTransactions = record.inventoryTransactions const recentTransactions = record.inventoryTransactions
.slice() .slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()) .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
@@ -391,6 +507,14 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
isSellable: record.isSellable, isSellable: record.isSellable,
isPurchasable: record.isPurchasable, isPurchasable: record.isPurchasable,
updatedAt: record.updatedAt, updatedAt: record.updatedAt,
inventoryTransactions: record.inventoryTransactions.map((transaction) => ({
transactionType: transaction.transactionType,
quantity: transaction.quantity,
})),
reservations: record.reservations.map((reservation) => ({
status: reservation.status,
quantity: reservation.quantity,
})),
_count: { bomLines: record.bomLines.length }, _count: { bomLines: record.bomLines.length },
}), }),
description: record.description, description: record.description,
@@ -398,6 +522,7 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
defaultPrice: record.defaultPrice, defaultPrice: record.defaultPrice,
preferredVendorId: record.preferredVendor?.id ?? null, preferredVendorId: record.preferredVendor?.id ?? null,
preferredVendorName: record.preferredVendor?.name ?? null, preferredVendorName: record.preferredVendor?.name ?? null,
skuBuilder,
notes: record.notes, notes: record.notes,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine), bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
@@ -502,6 +627,156 @@ function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
.filter((location) => location.code.length > 0 && location.name.length > 0); .filter((location) => location.code.length > 0 && location.name.length > 0);
} }
function normalizeSkuFamilyInput(input: InventorySkuFamilyInput) {
return {
code: input.code.trim().toUpperCase(),
sequenceCode: input.sequenceCode.trim().toUpperCase(),
name: input.name.trim(),
description: input.description.trim(),
isActive: input.isActive,
};
}
function normalizeSkuNodeInput(input: InventorySkuNodeInput) {
return {
familyId: input.familyId,
parentNodeId: input.parentNodeId,
code: input.code.trim(),
label: input.label.trim(),
description: input.description.trim(),
sortOrder: Number(input.sortOrder ?? 0),
isActive: input.isActive,
};
}
async function getSkuNodeLineage(nodeId: string) {
const lineage: Array<{
id: string;
familyId: string;
parentNodeId: string | null;
code: string;
label: string;
path: string;
level: number;
}> = [];
type SkuLineageNode = (typeof lineage)[number];
let currentId: string | null = nodeId;
while (currentId) {
const currentNode: SkuLineageNode | null = await prisma.inventorySkuNode.findUnique({
where: { id: currentId },
select: {
id: true,
familyId: true,
parentNodeId: true,
code: true,
label: true,
path: true,
level: true,
},
});
if (!currentNode) {
return null;
}
lineage.unshift(currentNode);
currentId = currentNode.parentNodeId;
}
return lineage;
}
async function buildSkuBuilderSelection(
family: { id: string; code: string; name: string; sequenceCode: string },
nodeId: string | null,
sequenceNumber: number | null
): Promise<InventorySkuBuilderSelectionDto> {
const lineage = nodeId ? await getSkuNodeLineage(nodeId) : [];
const segments = [family.code, ...(lineage ?? []).map((node) => node.code)];
const effectiveSequenceNumber = sequenceNumber ?? 0;
return {
familyId: family.id,
familyCode: family.code,
familyName: family.name,
sequenceCode: family.sequenceCode,
nodeId,
nodePath: (lineage ?? []).map((node) => ({
id: node.id,
code: node.code,
label: node.label,
level: node.level,
})),
sequenceNumber,
generatedSku: formatGeneratedSku(family.sequenceCode, segments, effectiveSequenceNumber),
segments,
};
}
async function validateSkuBuilder(input: InventorySkuBuilderInput | null) {
if (!input) {
return { ok: true as const, family: null, node: null, segments: [] as string[] };
}
const family = await prisma.inventorySkuFamily.findUnique({
where: { id: input.familyId },
select: {
id: true,
code: true,
name: true,
sequenceCode: true,
nextSequenceNumber: true,
isActive: true,
},
});
if (!family || !family.isActive) {
return { ok: false as const, reason: "Selected SKU family was not found or is inactive." };
}
let node:
| {
id: string;
familyId: string;
code: string;
label: string;
path: string;
level: number;
isActive: boolean;
}
| null = null;
let segments = [family.code];
if (input.nodeId) {
const lineage = await getSkuNodeLineage(input.nodeId);
if (!lineage || lineage.length === 0) {
return { ok: false as const, reason: "Selected SKU branch was not found." };
}
node = await prisma.inventorySkuNode.findUnique({
where: { id: input.nodeId },
select: {
id: true,
familyId: true,
code: true,
label: true,
path: true,
level: true,
isActive: true,
},
});
if (!node || node.familyId !== family.id || !node.isActive || lineage.some((entry) => entry.familyId !== family.id)) {
return { ok: false as const, reason: "Selected SKU branch is invalid for the chosen family." };
}
segments = [family.code, ...lineage.map((entry) => entry.code)];
}
return { ok: true as const, family, node, segments };
}
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) { async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({ const transactions = await prisma.inventoryTransaction.findMany({
where: { where: {
@@ -641,10 +916,204 @@ async function getActiveReservedQuantity(itemId: string, warehouseId: string, lo
return reservations.reduce((sum, reservation) => sum + reservation.quantity, 0); return reservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
} }
export async function listInventorySkuFamilies() {
const families = await prisma.inventorySkuFamily.findMany({
include: {
_count: {
select: {
nodes: true,
items: true,
},
},
},
orderBy: [{ code: "asc" }],
});
return families.map(mapSkuFamily);
}
export async function listInventorySkuCatalog(): Promise<InventorySkuCatalogTreeDto> {
const [families, nodes] = await Promise.all([
prisma.inventorySkuFamily.findMany({
include: {
_count: {
select: {
nodes: true,
items: true,
},
},
},
orderBy: [{ code: "asc" }],
}),
prisma.inventorySkuNode.findMany({
include: {
_count: {
select: {
childNodes: true,
},
},
},
orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { code: "asc" }],
}),
]);
return {
families: families.map(mapSkuFamily),
nodes: nodes.map(mapSkuNode),
};
}
export async function listInventorySkuNodeOptions(familyId: string, parentNodeId: string | null = null) {
const nodes = await prisma.inventorySkuNode.findMany({
where: {
familyId,
parentNodeId,
isActive: true,
},
include: {
_count: {
select: {
childNodes: true,
},
},
},
orderBy: [{ sortOrder: "asc" }, { code: "asc" }],
});
return nodes.map(mapSkuNode);
}
export async function previewInventorySku(input: InventorySkuBuilderInput): Promise<InventorySkuBuilderPreviewDto | null> {
const validated = await validateSkuBuilder(input);
if (!validated.ok || !validated.family) {
return null;
}
const childCount = validated.node
? await prisma.inventorySkuNode.count({
where: {
parentNodeId: validated.node.id,
isActive: true,
},
})
: await prisma.inventorySkuNode.count({
where: {
familyId: validated.family.id,
parentNodeId: null,
isActive: true,
},
});
return {
...(await buildSkuBuilderSelection(validated.family, validated.node?.id ?? null, validated.family.nextSequenceNumber)),
nextSequenceNumber: validated.family.nextSequenceNumber,
availableLevels: Math.max(0, 6 - validated.segments.length),
hasChildren: childCount > 0,
};
}
export async function createInventorySkuFamily(input: InventorySkuFamilyInput) {
const payload = normalizeSkuFamilyInput(input);
if (!/^[A-Z0-9]{2,12}$/.test(payload.code) || !/^[A-Z]{2}$/.test(payload.sequenceCode) || payload.name.length === 0) {
return null;
}
const family = await prisma.inventorySkuFamily.create({
data: payload,
include: {
_count: {
select: {
nodes: true,
items: true,
},
},
},
});
return mapSkuFamily(family);
}
export async function createInventorySkuNode(input: InventorySkuNodeInput) {
const payload = normalizeSkuNodeInput(input);
if (!payload.familyId || payload.code.length === 0 || payload.label.length === 0 || payload.code.includes("-")) {
return null;
}
const family = await prisma.inventorySkuFamily.findUnique({
where: { id: payload.familyId },
select: { id: true, code: true, isActive: true },
});
if (!family || !family.isActive) {
return null;
}
let level = 2;
let path = payload.code;
if (payload.parentNodeId) {
const parentNode = await prisma.inventorySkuNode.findUnique({
where: { id: payload.parentNodeId },
select: {
id: true,
familyId: true,
path: true,
level: true,
isActive: true,
},
});
if (!parentNode || parentNode.familyId !== family.id || !parentNode.isActive || parentNode.level >= 6) {
return null;
}
level = parentNode.level + 1;
path = `${parentNode.path}/${payload.code}`;
}
if (level > 6) {
return null;
}
const node = await prisma.inventorySkuNode.create({
data: {
familyId: family.id,
parentNodeId: payload.parentNodeId,
code: payload.code,
label: payload.label,
description: payload.description,
path,
level,
sortOrder: payload.sortOrder,
isActive: payload.isActive,
},
include: {
_count: {
select: {
childNodes: true,
},
},
},
});
return mapSkuNode(node);
}
export async function listInventoryItems(filters: InventoryListFilters = {}) { export async function listInventoryItems(filters: InventoryListFilters = {}) {
const items = await prisma.inventoryItem.findMany({ const items = await prisma.inventoryItem.findMany({
where: buildWhereClause(filters), where: buildWhereClause(filters),
include: { include: {
inventoryTransactions: {
select: {
transactionType: true,
quantity: true,
},
},
reservations: {
select: {
status: true,
quantity: true,
},
},
_count: { _count: {
select: { select: {
bomLines: true, bomLines: true,
@@ -697,6 +1166,24 @@ export async function getInventoryItemById(itemId: string) {
const item = await prisma.inventoryItem.findUnique({ const item = await prisma.inventoryItem.findUnique({
where: { id: itemId }, where: { id: itemId },
include: { include: {
skuFamily: {
select: {
id: true,
code: true,
sequenceCode: true,
name: true,
},
},
skuNode: {
select: {
id: true,
code: true,
label: true,
path: true,
level: true,
parentNodeId: true,
},
},
bomLines: { bomLines: {
include: { include: {
componentItem: { componentItem: {
@@ -819,7 +1306,16 @@ export async function getInventoryItemById(itemId: string) {
}, },
}); });
return item ? mapDetail(item) : null; if (!item) {
return null;
}
const skuBuilder =
item.skuFamily && item.skuSequenceNumber
? await buildSkuBuilderSelection(item.skuFamily, item.skuNode?.id ?? null, item.skuSequenceNumber)
: null;
return mapDetail(item as InventoryDetailRecord, skuBuilder);
} }
export async function listWarehouseLocationOptions() { export async function listWarehouseLocationOptions() {
@@ -1077,35 +1573,89 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
if (!validatedPreferredVendor.ok) { if (!validatedPreferredVendor.ok) {
return null; return null;
} }
const validatedSku = await validateSkuBuilder(payload.skuBuilder);
if (!validatedSku.ok) {
return null;
}
const item = await prisma.inventoryItem.create({ const item = await prisma.$transaction(async (transaction) => {
data: { if (validatedSku.family) {
sku: payload.sku, const sequenceNumber = validatedSku.family.nextSequenceNumber;
name: payload.name, await transaction.inventorySkuFamily.update({
description: payload.description, where: { id: validatedSku.family.id },
type: payload.type, data: {
status: payload.status, nextSequenceNumber: {
unitOfMeasure: payload.unitOfMeasure, increment: 1,
isSellable: payload.isSellable, },
isPurchasable: payload.isPurchasable, },
preferredVendorId: payload.preferredVendorId, });
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice, return transaction.inventoryItem.create({
notes: payload.notes, data: {
bomLines: validatedBom.bomLines.length sku: formatGeneratedSku(validatedSku.family.sequenceCode, validatedSku.segments, sequenceNumber),
? { skuFamilyId: validatedSku.family.id,
create: validatedBom.bomLines, skuNodeId: validatedSku.node?.id ?? null,
} skuSequenceNumber: sequenceNumber,
: undefined, name: payload.name,
operations: validatedOperations.operations.length description: payload.description,
? { type: payload.type,
create: validatedOperations.operations, status: payload.status,
} unitOfMeasure: payload.unitOfMeasure,
: undefined, isSellable: payload.isSellable,
}, isPurchasable: payload.isPurchasable,
select: { preferredVendorId: payload.preferredVendorId,
id: true, defaultCost: payload.defaultCost,
}, defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: validatedBom.bomLines.length
? {
create: validatedBom.bomLines,
}
: undefined,
operations: validatedOperations.operations.length
? {
create: validatedOperations.operations,
}
: undefined,
},
select: {
id: true,
},
});
}
return transaction.inventoryItem.create({
data: {
sku: payload.sku,
skuFamilyId: null,
skuNodeId: null,
skuSequenceNumber: null,
name: payload.name,
description: payload.description,
type: payload.type,
status: payload.status,
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: validatedBom.bomLines.length
? {
create: validatedBom.bomLines,
}
: undefined,
operations: validatedOperations.operations.length
? {
create: validatedOperations.operations,
}
: undefined,
},
select: {
id: true,
},
});
}); });
await logAuditEvent({ await logAuditEvent({
@@ -1113,9 +1663,11 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
entityType: "inventory-item", entityType: "inventory-item",
entityId: item.id, entityId: item.id,
action: "created", action: "created",
summary: `Created inventory item ${payload.sku}.`, summary: `Created inventory item ${validatedSku.family ? "generated SKU" : payload.sku}.`,
metadata: { metadata: {
sku: payload.sku, sku: payload.sku,
skuFamilyId: validatedSku.family?.id ?? null,
skuNodeId: validatedSku.node?.id ?? null,
name: payload.name, name: payload.name,
type: payload.type, type: payload.type,
status: payload.status, status: payload.status,
@@ -1146,34 +1698,75 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
if (!validatedPreferredVendor.ok) { if (!validatedPreferredVendor.ok) {
return null; return null;
} }
const validatedSku = await validateSkuBuilder(payload.skuBuilder);
if (!validatedSku.ok) {
return null;
}
const item = await prisma.inventoryItem.update({ const item = await prisma.$transaction(async (transaction) => {
where: { id: itemId }, const shouldKeepExistingGeneratedSku =
data: { validatedSku.family &&
sku: payload.sku, existingItem.skuFamilyId === validatedSku.family.id &&
name: payload.name, existingItem.skuNodeId === (validatedSku.node?.id ?? null) &&
description: payload.description, existingItem.skuSequenceNumber != null;
type: payload.type,
status: payload.status, let sku = payload.sku;
unitOfMeasure: payload.unitOfMeasure, let skuFamilyId: string | null = null;
isSellable: payload.isSellable, let skuNodeId: string | null = null;
isPurchasable: payload.isPurchasable, let skuSequenceNumber: number | null = null;
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost, if (validatedSku.family) {
defaultPrice: payload.defaultPrice, skuFamilyId = validatedSku.family.id;
notes: payload.notes, skuNodeId = validatedSku.node?.id ?? null;
bomLines: {
deleteMany: {}, if (shouldKeepExistingGeneratedSku) {
create: validatedBom.bomLines, sku = existingItem.sku;
skuSequenceNumber = existingItem.skuSequenceNumber;
} else {
skuSequenceNumber = validatedSku.family.nextSequenceNumber;
sku = formatGeneratedSku(validatedSku.family.sequenceCode, validatedSku.segments, skuSequenceNumber);
await transaction.inventorySkuFamily.update({
where: { id: validatedSku.family.id },
data: {
nextSequenceNumber: {
increment: 1,
},
},
});
}
}
return transaction.inventoryItem.update({
where: { id: itemId },
data: {
sku,
skuFamilyId,
skuNodeId,
skuSequenceNumber,
name: payload.name,
description: payload.description,
type: payload.type,
status: payload.status,
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: {
deleteMany: {},
create: validatedBom.bomLines,
},
operations: {
deleteMany: {},
create: validatedOperations.operations,
},
}, },
operations: { select: {
deleteMany: {}, id: true,
create: validatedOperations.operations,
}, },
}, });
select: {
id: true,
},
}); });
await logAuditEvent({ await logAuditEvent({
@@ -1184,6 +1777,8 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
summary: `Updated inventory item ${payload.sku}.`, summary: `Updated inventory item ${payload.sku}.`,
metadata: { metadata: {
sku: payload.sku, sku: payload.sku,
skuFamilyId: validatedSku.family?.id ?? null,
skuNodeId: validatedSku.node?.id ?? null,
name: payload.name, name: payload.name,
type: payload.type, type: payload.type,
status: payload.status, status: payload.status,

View File

@@ -13,9 +13,16 @@ import {
listManufacturingItemOptions, listManufacturingItemOptions,
listManufacturingProjectOptions, listManufacturingProjectOptions,
listManufacturingStations, listManufacturingStations,
listManufacturingUserOptions,
listWorkOrders, listWorkOrders,
recordWorkOrderCompletion, recordWorkOrderCompletion,
recordWorkOrderOperationLabor,
updateManufacturingStation,
updateWorkOrderOperationAssignment,
updateWorkOrderOperationExecution,
updateWorkOrderOperationTimer,
updateWorkOrder, updateWorkOrder,
updateWorkOrderOperationSchedule,
updateWorkOrderStatus, updateWorkOrderStatus,
} from "./service.js"; } from "./service.js";
@@ -24,6 +31,9 @@ const stationSchema = z.object({
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
description: z.string(), description: z.string(),
queueDays: z.number().int().min(0).max(365), queueDays: z.number().int().min(0).max(365),
dailyCapacityMinutes: z.number().int().min(60).max(1440),
parallelCapacity: z.number().int().min(1).max(24),
workingDays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
isActive: z.boolean(), isActive: z.boolean(),
}); });
@@ -49,6 +59,7 @@ const workOrderFiltersSchema = z.object({
const statusUpdateSchema = z.object({ const statusUpdateSchema = z.object({
status: z.enum(workOrderStatuses), status: z.enum(workOrderStatuses),
reason: z.string().nullable().optional(),
}); });
const materialIssueSchema = z.object({ const materialIssueSchema = z.object({
@@ -64,6 +75,30 @@ const completionSchema = z.object({
notes: z.string(), notes: z.string(),
}); });
const operationScheduleSchema = z.object({
plannedStart: z.string().datetime(),
stationId: z.string().trim().min(1).nullable().optional(),
});
const operationExecutionSchema = z.object({
action: z.enum(["START", "PAUSE", "RESUME", "COMPLETE"]),
notes: z.string(),
});
const operationLaborSchema = z.object({
minutes: z.number().int().positive(),
notes: z.string(),
});
const operationAssignmentSchema = z.object({
assignedOperatorId: z.string().trim().min(1).nullable(),
});
const operationTimerSchema = z.object({
action: z.enum(["START", "STOP"]),
notes: z.string(),
});
function getRouteParam(value: unknown) { function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
@@ -78,6 +113,10 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
return ok(response, await listManufacturingProjectOptions()); return ok(response, await listManufacturingProjectOptions());
}); });
manufacturingRouter.get("/users/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingUserOptions());
});
manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => { manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingStations()); return ok(response, await listManufacturingStations());
}); });
@@ -91,6 +130,25 @@ manufacturingRouter.post("/stations", requirePermissions([permissions.manufactur
return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201); return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201);
}); });
manufacturingRouter.put("/stations/:stationId", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const stationId = getRouteParam(request.params.stationId);
if (!stationId) {
return fail(response, 400, "INVALID_INPUT", "Manufacturing station id is invalid.");
}
const parsed = stationSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
}
const result = await updateManufacturingStation(stationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 404, "STATION_NOT_FOUND", result.reason);
}
return ok(response, result.station);
});
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => { manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const parsed = workOrderFiltersSchema.safeParse(request.query); const parsed = workOrderFiltersSchema.safeParse(request.query);
if (!parsed.success) { if (!parsed.success) {
@@ -158,7 +216,107 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
} }
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id); const result = await updateWorkOrderStatus(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/schedule", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationScheduleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation schedule payload is invalid.");
}
const result = await updateWorkOrderOperationSchedule(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/execution", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationExecutionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation execution payload is invalid.");
}
const result = await updateWorkOrderOperationExecution(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/operations/:operationId/labor", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationLaborSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation labor payload is invalid.");
}
const result = await recordWorkOrderOperationLabor(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/assignment", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationAssignmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation assignment payload is invalid.");
}
const result = await updateWorkOrderOperationAssignment(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/timer", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationTimerSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation timer payload is invalid.");
}
const result = await updateWorkOrderOperationTimer(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { permissions, projectPriorities, projectStatuses } from "@mrp/shared"; import { permissions, projectMilestoneStatuses, projectPriorities, projectStatuses } from "@mrp/shared";
import { Router } from "express"; import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
@@ -14,6 +14,7 @@ import {
listProjectQuoteOptions, listProjectQuoteOptions,
listProjectShipmentOptions, listProjectShipmentOptions,
updateProject, updateProject,
updateProjectMilestoneStatus,
} from "./service.js"; } from "./service.js";
const projectSchema = z.object({ const projectSchema = z.object({
@@ -27,6 +28,16 @@ const projectSchema = z.object({
ownerId: z.string().trim().min(1).nullable(), ownerId: z.string().trim().min(1).nullable(),
dueDate: z.string().datetime().nullable(), dueDate: z.string().datetime().nullable(),
notes: z.string(), notes: z.string(),
milestones: z.array(
z.object({
id: z.string().trim().min(1).nullable().optional(),
title: z.string().trim().min(1).max(160),
status: z.enum(projectMilestoneStatuses),
dueDate: z.string().datetime().nullable(),
notes: z.string(),
sortOrder: z.number().int(),
})
),
}); });
const projectListQuerySchema = z.object({ const projectListQuerySchema = z.object({
@@ -41,6 +52,10 @@ const projectOptionQuerySchema = z.object({
customerId: z.string().optional(), customerId: z.string().optional(),
}); });
const milestoneStatusSchema = z.object({
status: z.enum(projectMilestoneStatuses),
});
function getRouteParam(value: unknown) { function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
@@ -137,3 +152,23 @@ projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]
return ok(response, result.project); return ok(response, result.project);
}); });
projectsRouter.patch("/:projectId/milestones/:milestoneId/status", requirePermissions([permissions.projectsWrite]), async (request, response) => {
const projectId = getRouteParam(request.params.projectId);
const milestoneId = getRouteParam(request.params.milestoneId);
if (!projectId || !milestoneId) {
return fail(response, 400, "INVALID_INPUT", "Project or milestone id is invalid.");
}
const parsed = milestoneStatusSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project milestone status payload is invalid.");
}
const result = await updateProjectMilestoneStatus(projectId, milestoneId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.project);
});

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import {
createPurchaseReceipt, createPurchaseReceipt,
createPurchaseOrder, createPurchaseOrder,
getPurchaseOrderById, getPurchaseOrderById,
listPurchaseOrderRevisions,
listPurchaseOrders, listPurchaseOrders,
listPurchaseVendorOptions, listPurchaseVendorOptions,
updatePurchaseOrder, updatePurchaseOrder,
@@ -28,11 +29,13 @@ const purchaseLineSchema = z.object({
const purchaseOrderSchema = z.object({ const purchaseOrderSchema = z.object({
vendorId: z.string().trim().min(1), vendorId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable().optional(),
status: z.enum(purchaseOrderStatuses), status: z.enum(purchaseOrderStatuses),
issueDate: z.string().datetime(), issueDate: z.string().datetime(),
taxPercent: z.number().min(0).max(100), taxPercent: z.number().min(0).max(100),
freightAmount: z.number().nonnegative(), freightAmount: z.number().nonnegative(),
notes: z.string(), notes: z.string(),
revisionReason: z.string().optional(),
lines: z.array(purchaseLineSchema), lines: z.array(purchaseLineSchema),
}); });
@@ -92,6 +95,20 @@ purchasingRouter.get("/orders/:orderId", requirePermissions(["purchasing.read"])
return ok(response, order); return ok(response, order);
}); });
purchasingRouter.get("/orders/:orderId/revisions", 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, await listPurchaseOrderRevisions(orderId));
});
purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => { purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => {
const parsed = purchaseOrderSchema.safeParse(request.body); const parsed = purchaseOrderSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {

View File

@@ -1,5 +1,14 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import type { PurchaseLineInput, PurchaseOrderDetailDto, PurchaseOrderInput, PurchaseOrderStatus, PurchaseOrderSummaryDto, PurchaseVendorOptionDto } from "@mrp/shared"; import type {
PurchaseLineInput,
PurchaseOrderDetailDto,
PurchaseOrderInput,
PurchaseOrderRevisionDto,
PurchaseOrderRevisionSnapshotDto,
PurchaseOrderStatus,
PurchaseOrderSummaryDto,
PurchaseVendorOptionDto,
} from "@mrp/shared";
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js"; import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import { logAuditEvent } from "../../lib/audit.js"; import { logAuditEvent } from "../../lib/audit.js";
@@ -102,9 +111,26 @@ type PurchaseReceiptRecord = {
lines: PurchaseReceiptLineRecord[]; lines: PurchaseReceiptLineRecord[];
}; };
type PurchaseOrderRevisionRecord = {
id: string;
revisionNumber: number;
reason: string;
snapshot: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
};
type PurchaseOrderRecord = { type PurchaseOrderRecord = {
id: string; id: string;
documentNumber: string; documentNumber: string;
project: {
id: string;
projectNumber: string;
name: string;
} | null;
status: string; status: string;
issueDate: Date; issueDate: Date;
taxPercent: number; taxPercent: number;
@@ -121,6 +147,18 @@ type PurchaseOrderRecord = {
}; };
lines: PurchaseLineRecord[]; lines: PurchaseLineRecord[];
receipts: PurchaseReceiptRecord[]; receipts: PurchaseReceiptRecord[];
revisions: PurchaseOrderRevisionRecord[];
};
type NormalizedPurchaseLine = {
itemId: string;
salesOrderId: string | null;
salesOrderLineId: string | null;
description: string;
quantity: number;
unitOfMeasure: PurchaseLineInput["unitOfMeasure"];
unitCost: number;
position: number;
}; };
function roundMoney(value: number) { function roundMoney(value: number) {
@@ -147,6 +185,10 @@ function getCreatedByName(createdBy: PurchaseReceiptRecord["createdBy"]) {
return createdBy ? `${createdBy.firstName} ${createdBy.lastName}`.trim() : "System"; return createdBy ? `${createdBy.firstName} ${createdBy.lastName}`.trim() : "System";
} }
function getUserDisplayName(user: { firstName: string; lastName: string } | null) {
return user ? `${user.firstName} ${user.lastName}`.trim() : null;
}
function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: string): PurchaseReceiptDto { function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: string): PurchaseReceiptDto {
const lines = record.lines.map((line: PurchaseReceiptLineRecord) => ({ const lines = record.lines.map((line: PurchaseReceiptLineRecord) => ({
id: line.id, id: line.id,
@@ -177,6 +219,21 @@ function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: stri
}; };
} }
function parseRevisionSnapshot(snapshot: string): PurchaseOrderRevisionSnapshotDto {
return JSON.parse(snapshot) as PurchaseOrderRevisionSnapshotDto;
}
function mapPurchaseOrderRevision(record: PurchaseOrderRevisionRecord): PurchaseOrderRevisionDto {
return {
id: record.id,
revisionNumber: record.revisionNumber,
reason: record.reason,
createdAt: record.createdAt.toISOString(),
createdByName: getUserDisplayName(record.createdBy),
snapshot: parseRevisionSnapshot(record.snapshot),
};
}
function normalizeLines(lines: PurchaseLineInput[]) { function normalizeLines(lines: PurchaseLineInput[]) {
return lines return lines
.map((line, index) => ({ .map((line, index) => ({
@@ -281,6 +338,63 @@ async function validateLines(lines: PurchaseLineInput[]) {
return { ok: true as const, lines: normalized }; return { ok: true as const, lines: normalized };
} }
async function resolvePurchaseOrderProjectId(projectId: string | null | undefined, lines: NormalizedPurchaseLine[]) {
let explicitProjectId = projectId ?? null;
if (explicitProjectId) {
const project = await prisma.project.findUnique({
where: { id: explicitProjectId },
select: {
id: true,
salesOrderId: true,
},
});
if (!project) {
return { ok: false as const, reason: "Linked project was not found." };
}
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
if (linkedSalesOrderIds.length > 0 && project.salesOrderId && linkedSalesOrderIds.some((salesOrderId) => salesOrderId !== project.salesOrderId)) {
return { ok: false as const, reason: "Linked project does not match the sales-order demand attached to this purchase order." };
}
return { ok: true as const, projectId: project.id };
}
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
if (linkedSalesOrderIds.length === 0) {
return { ok: true as const, projectId: null };
}
const matchingProjects = await prisma.project.findMany({
where: {
salesOrderId: {
in: linkedSalesOrderIds,
},
},
select: {
id: true,
salesOrderId: true,
createdAt: true,
},
orderBy: [{ createdAt: "asc" }],
});
const projectBySalesOrderId = new Map<string, string>();
for (const project of matchingProjects) {
if (project.salesOrderId && !projectBySalesOrderId.has(project.salesOrderId)) {
projectBySalesOrderId.set(project.salesOrderId, project.id);
}
}
const derivedProjectIds = [...new Set(linkedSalesOrderIds.map((salesOrderId) => projectBySalesOrderId.get(salesOrderId)).filter((value): value is string => Boolean(value)))];
if (derivedProjectIds.length > 1) {
return { ok: false as const, reason: "Purchase orders can only auto-link to one project. Split the document or set the project intentionally." };
}
return { ok: true as const, projectId: derivedProjectIds[0] ?? null };
}
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto { function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
const receivedByLineId = new Map<string, number>(); const receivedByLineId = new Map<string, number>();
@@ -319,12 +433,19 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
.slice() .slice()
.sort((left, right) => right.receivedAt.getTime() - left.receivedAt.getTime()) .sort((left, right) => right.receivedAt.getTime() - left.receivedAt.getTime())
.map((receipt) => mapPurchaseReceipt(receipt, record.id)); .map((receipt) => mapPurchaseReceipt(receipt, record.id));
const revisions = record.revisions
.slice()
.sort((left, right) => right.revisionNumber - left.revisionNumber)
.map(mapPurchaseOrderRevision);
return { return {
id: record.id, id: record.id,
documentNumber: record.documentNumber, documentNumber: record.documentNumber,
vendorId: record.vendor.id, vendorId: record.vendor.id,
vendorName: record.vendor.name, vendorName: record.vendor.name,
projectId: record.project?.id ?? null,
projectNumber: record.project?.projectNumber ?? null,
projectName: record.project?.name ?? null,
vendorEmail: record.vendor.email, vendorEmail: record.vendor.email,
paymentTerms: record.vendor.paymentTerms, paymentTerms: record.vendor.paymentTerms,
currencyCode: record.vendor.currencyCode, currencyCode: record.vendor.currencyCode,
@@ -341,9 +462,87 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
lineCount: lines.length, lineCount: lines.length,
lines, lines,
receipts, receipts,
revisions,
}; };
} }
function buildPurchaseOrderRevisionSnapshot(document: PurchaseOrderDetailDto) {
return JSON.stringify({
documentNumber: document.documentNumber,
vendorId: document.vendorId,
vendorName: document.vendorName,
status: document.status,
issueDate: document.issueDate,
taxPercent: document.taxPercent,
taxAmount: document.taxAmount,
freightAmount: document.freightAmount,
subtotal: document.subtotal,
total: document.total,
notes: document.notes,
paymentTerms: document.paymentTerms,
currencyCode: document.currencyCode,
lines: document.lines.map((line) => ({
itemId: line.itemId,
itemSku: line.itemSku,
itemName: line.itemName,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitCost: line.unitCost,
lineTotal: line.lineTotal,
receivedQuantity: line.receivedQuantity,
remainingQuantity: line.remainingQuantity,
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
salesOrderNumber: line.salesOrderNumber,
position: line.position,
})),
receipts: document.receipts.map((receipt) => ({
id: receipt.id,
receiptNumber: receipt.receiptNumber,
purchaseOrderId: receipt.purchaseOrderId,
receivedAt: receipt.receivedAt,
notes: receipt.notes,
createdAt: receipt.createdAt,
createdByName: receipt.createdByName,
warehouseId: receipt.warehouseId,
warehouseCode: receipt.warehouseCode,
warehouseName: receipt.warehouseName,
locationId: receipt.locationId,
locationCode: receipt.locationCode,
locationName: receipt.locationName,
totalQuantity: receipt.totalQuantity,
lineCount: receipt.lineCount,
lines: receipt.lines.map((line) => ({
id: line.id,
purchaseOrderLineId: line.purchaseOrderLineId,
itemId: line.itemId,
itemSku: line.itemSku,
itemName: line.itemName,
quantity: line.quantity,
})),
})),
});
}
async function createPurchaseOrderRevision(documentId: string, detail: PurchaseOrderDetailDto, reason: string, actorId?: string | null) {
const aggregate = await prisma.purchaseOrderRevision.aggregate({
where: { purchaseOrderId: documentId },
_max: { revisionNumber: true },
});
const nextRevisionNumber = (aggregate._max.revisionNumber ?? 0) + 1;
await prisma.purchaseOrderRevision.create({
data: {
purchaseOrderId: documentId,
revisionNumber: nextRevisionNumber,
reason,
snapshot: buildPurchaseOrderRevisionSnapshot(detail),
createdById: actorId ?? null,
},
});
}
async function nextDocumentNumber() { async function nextDocumentNumber() {
const next = (await purchaseOrderModel.count()) + 1; const next = (await purchaseOrderModel.count()) + 1;
return `PO-${String(next).padStart(5, "0")}`; return `PO-${String(next).padStart(5, "0")}`;
@@ -364,6 +563,13 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
currencyCode: true, currencyCode: true,
}, },
}, },
project: {
select: {
id: true,
projectNumber: true,
name: true,
},
},
lines: { lines: {
include: { include: {
item: { item: {
@@ -423,6 +629,17 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
}, },
orderBy: [{ receivedAt: "desc" }, { createdAt: "desc" }], orderBy: [{ receivedAt: "desc" }, { createdAt: "desc" }],
}, },
revisions: {
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revisionNumber: "desc" }],
},
}); });
function normalizeReceiptLines(lines: PurchaseReceiptInput["lines"]) { function normalizeReceiptLines(lines: PurchaseReceiptInput["lines"]) {
@@ -581,6 +798,9 @@ export async function listPurchaseOrders(filters: { q?: string; status?: Purchas
documentNumber: detail.documentNumber, documentNumber: detail.documentNumber,
vendorId: detail.vendorId, vendorId: detail.vendorId,
vendorName: detail.vendorName, vendorName: detail.vendorName,
projectId: detail.projectId,
projectNumber: detail.projectNumber,
projectName: detail.projectName,
status: detail.status, status: detail.status,
subtotal: detail.subtotal, subtotal: detail.subtotal,
taxPercent: detail.taxPercent, taxPercent: detail.taxPercent,
@@ -605,12 +825,34 @@ export async function getPurchaseOrderById(documentId: string) {
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null; return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
} }
export async function listPurchaseOrderRevisions(documentId: string) {
const revisions = await prisma.purchaseOrderRevision.findMany({
where: { purchaseOrderId: documentId },
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revisionNumber: "desc" }],
});
return revisions.map((revision) => mapPurchaseOrderRevision(revision as PurchaseOrderRevisionRecord));
}
export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) { export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) {
const validatedLines = await validateLines(payload.lines); const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) { if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason }; return { ok: false as const, reason: validatedLines.reason };
} }
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
if (!resolvedProject.ok) {
return { ok: false as const, reason: resolvedProject.reason };
}
const vendor = await prisma.vendor.findUnique({ const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId }, where: { id: payload.vendorId },
select: { id: true }, select: { id: true },
@@ -626,6 +868,7 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
data: { data: {
documentNumber, documentNumber,
vendorId: payload.vendorId, vendorId: payload.vendorId,
projectId: resolvedProject.projectId,
status: payload.status, status: payload.status,
issueDate: new Date(payload.issueDate), issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent, taxPercent: payload.taxPercent,
@@ -640,6 +883,7 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
const detail = await getPurchaseOrderById(created.id); const detail = await getPurchaseOrderById(created.id);
if (detail) { if (detail) {
await createPurchaseOrderRevision(created.id, detail, payload.revisionReason?.trim() || "Initial issue", actorId);
await logAuditEvent({ await logAuditEvent({
actorId, actorId,
entityType: "purchase-order", entityType: "purchase-order",
@@ -672,6 +916,11 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
return { ok: false as const, reason: validatedLines.reason }; return { ok: false as const, reason: validatedLines.reason };
} }
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
if (!resolvedProject.ok) {
return { ok: false as const, reason: resolvedProject.reason };
}
const vendor = await prisma.vendor.findUnique({ const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId }, where: { id: payload.vendorId },
select: { id: true }, select: { id: true },
@@ -685,6 +934,7 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
where: { id: documentId }, where: { id: documentId },
data: { data: {
vendorId: payload.vendorId, vendorId: payload.vendorId,
projectId: resolvedProject.projectId,
status: payload.status, status: payload.status,
issueDate: new Date(payload.issueDate), issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent, taxPercent: payload.taxPercent,
@@ -700,6 +950,7 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
const detail = await getPurchaseOrderById(documentId); const detail = await getPurchaseOrderById(documentId);
if (detail) { if (detail) {
await createPurchaseOrderRevision(documentId, detail, payload.revisionReason?.trim() || "Document edited", actorId);
await logAuditEvent({ await logAuditEvent({
actorId, actorId,
entityType: "purchase-order", entityType: "purchase-order",
@@ -735,6 +986,7 @@ export async function updatePurchaseOrderStatus(documentId: string, status: Purc
const detail = await getPurchaseOrderById(documentId); const detail = await getPurchaseOrderById(documentId);
if (detail) { if (detail) {
await createPurchaseOrderRevision(documentId, detail, `Status changed to ${status}`, actorId);
await logAuditEvent({ await logAuditEvent({
actorId, actorId,
entityType: "purchase-order", entityType: "purchase-order",
@@ -796,6 +1048,7 @@ export async function createPurchaseReceipt(orderId: string, payload: PurchaseRe
const detail = await getPurchaseOrderById(orderId); const detail = await getPurchaseOrderById(orderId);
if (detail) { if (detail) {
await createPurchaseOrderRevision(orderId, detail, `Receipt posted on ${payload.receivedAt}`, createdById);
await logAuditEvent({ await logAuditEvent({
actorId: createdById, actorId: createdById,
entityType: "purchase-order", entityType: "purchase-order",

View File

@@ -3,6 +3,7 @@ import type {
SalesDocumentDetailDto, SalesDocumentDetailDto,
SalesDocumentInput, SalesDocumentInput,
SalesDocumentRevisionDto, SalesDocumentRevisionDto,
SalesDocumentRevisionSnapshotDto,
SalesDocumentStatus, SalesDocumentStatus,
SalesDocumentSummaryDto, SalesDocumentSummaryDto,
SalesDocumentType, SalesDocumentType,
@@ -66,6 +67,7 @@ type RevisionRecord = {
id: string; id: string;
revisionNumber: number; revisionNumber: number;
reason: string; reason: string;
snapshot: string;
createdAt: Date; createdAt: Date;
createdBy: { createdBy: {
firstName: string; firstName: string;
@@ -95,6 +97,12 @@ type SalesDocumentRecord = {
firstName: string; firstName: string;
lastName: string; lastName: string;
} | null; } | null;
projects: Array<{
id: string;
projectNumber: string;
name: string;
createdAt: Date;
}>;
revisions: RevisionRecord[]; revisions: RevisionRecord[];
lines: SalesLineRecord[]; lines: SalesLineRecord[];
}; };
@@ -172,6 +180,10 @@ function getUserDisplayName(user: { firstName: string; lastName: string } | null
return `${user.firstName} ${user.lastName}`.trim(); return `${user.firstName} ${user.lastName}`.trim();
} }
function parseRevisionSnapshot(snapshot: string): SalesDocumentRevisionSnapshotDto {
return JSON.parse(snapshot) as SalesDocumentRevisionSnapshotDto;
}
function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto { function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
return { return {
id: record.id, id: record.id,
@@ -179,6 +191,7 @@ function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
reason: record.reason, reason: record.reason,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
createdByName: getUserDisplayName(record.createdBy), createdByName: getUserDisplayName(record.createdBy),
snapshot: parseRevisionSnapshot(record.snapshot),
}; };
} }
@@ -272,6 +285,9 @@ function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
notes: record.notes, notes: record.notes,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(), updatedAt: record.updatedAt.toISOString(),
linkedProjectId: record.projects[0]?.id ?? null,
linkedProjectNumber: record.projects[0]?.projectNumber ?? null,
linkedProjectName: record.projects[0]?.name ?? null,
lineCount: lines.length, lineCount: lines.length,
lines, lines,
revisions, revisions,
@@ -376,6 +392,15 @@ function buildInclude() {
lastName: true, lastName: true,
}, },
}, },
projects: {
select: {
id: true,
projectNumber: true,
name: true,
createdAt: true,
},
orderBy: [{ createdAt: "asc" as const }],
},
revisions: { revisions: {
include: { include: {
createdBy: { createdBy: {
@@ -792,6 +817,16 @@ export async function convertQuoteToSalesOrder(quoteId: string, userId?: string)
select: { id: true }, select: { id: true },
}); });
await tx.project.updateMany({
where: {
salesQuoteId: quoteId,
salesOrderId: null,
},
data: {
salesOrderId: created.id,
},
});
return created.id; return created.id;
}); });

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import { fail, ok } from "../../lib/http.js"; import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js"; import { requirePermissions } from "../../lib/rbac.js";
import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, updateShipment, updateShipmentStatus } from "./service.js"; import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, postShipmentPick, updateShipment, updateShipmentStatus } from "./service.js";
const shipmentSchema = z.object({ const shipmentSchema = z.object({
salesOrderId: z.string().trim().min(1), salesOrderId: z.string().trim().min(1),
@@ -28,6 +28,14 @@ const shipmentStatusUpdateSchema = z.object({
status: z.enum(shipmentStatuses), status: z.enum(shipmentStatuses),
}); });
const shipmentPickSchema = z.object({
salesOrderLineId: z.string().trim().min(1),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
quantity: z.number().positive(),
notes: z.string(),
});
function getRouteParam(value: unknown) { function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
@@ -112,3 +120,22 @@ shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permis
return ok(response, result.shipment); return ok(response, result.shipment);
}); });
shippingRouter.post("/shipments/:shipmentId/picks", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const parsed = shipmentPickSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment pick payload is invalid.");
}
const result = await postShipmentPick(shipmentId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment, 201);
});

View File

@@ -1,6 +1,7 @@
import type { import type {
ShipmentDetailDto, ShipmentDetailDto,
ShipmentInput, ShipmentInput,
ShipmentPickInput,
ShipmentOrderOptionDto, ShipmentOrderOptionDto,
ShipmentStatus, ShipmentStatus,
ShipmentSummaryDto, ShipmentSummaryDto,
@@ -61,10 +62,83 @@ type ShipmentRecord = {
customer: { customer: {
name: string; name: string;
}; };
lines: Array<{
id: string;
description: string;
quantity: number;
unitOfMeasure: string;
item: {
id: string;
sku: string;
name: string;
};
}>;
}; };
picks: Array<{
id: string;
salesOrderLineId: string;
quantity: number;
notes: string;
createdAt: Date;
item: {
id: string;
sku: string;
name: string;
};
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
}; };
function mapShipment(record: ShipmentRecord): ShipmentDetailDto { function mapShipmentSummary(record: {
id: string;
shipmentNumber: string;
status: string;
shipDate: Date | null;
carrier: string;
trackingNumber: string;
packageCount: number;
updatedAt: Date;
salesOrder: {
id: string;
documentNumber: string;
customer: {
name: string;
};
};
}): ShipmentSummaryDto {
return {
id: record.id,
shipmentNumber: record.shipmentNumber,
salesOrderId: record.salesOrder.id,
salesOrderNumber: record.salesOrder.documentNumber,
customerName: record.salesOrder.customer.name,
status: record.status as ShipmentStatus,
carrier: record.carrier,
trackingNumber: record.trackingNumber,
packageCount: record.packageCount,
shipDate: record.shipDate ? record.shipDate.toISOString() : null,
updatedAt: record.updatedAt.toISOString(),
};
}
function mapShipmentDetail(record: ShipmentRecord): ShipmentDetailDto {
const pickedByLineId = new Map<string, number>();
for (const pick of record.picks) {
pickedByLineId.set(pick.salesOrderLineId, (pickedByLineId.get(pick.salesOrderLineId) ?? 0) + pick.quantity);
}
return { return {
id: record.id, id: record.id,
shipmentNumber: record.shipmentNumber, shipmentNumber: record.shipmentNumber,
@@ -80,9 +154,58 @@ function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
notes: record.notes, notes: record.notes,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(), updatedAt: record.updatedAt.toISOString(),
lines: record.salesOrder.lines.map((line) => {
const pickedQuantity = pickedByLineId.get(line.id) ?? 0;
return {
salesOrderLineId: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
orderedQuantity: line.quantity,
pickedQuantity,
remainingQuantity: Math.max(line.quantity - pickedQuantity, 0),
unitOfMeasure: line.unitOfMeasure,
};
}),
picks: record.picks.map((pick) => ({
id: pick.id,
salesOrderLineId: pick.salesOrderLineId,
itemId: pick.item.id,
itemSku: pick.item.sku,
itemName: pick.item.name,
quantity: pick.quantity,
warehouseId: pick.warehouse.id,
warehouseCode: pick.warehouse.code,
warehouseName: pick.warehouse.name,
locationId: pick.location.id,
locationCode: pick.location.code,
locationName: pick.location.name,
notes: pick.notes,
createdAt: pick.createdAt.toISOString(),
createdByName: pick.createdBy ? `${pick.createdBy.firstName} ${pick.createdBy.lastName}`.trim() : "System",
})),
}; };
} }
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({
where: {
itemId,
warehouseId,
locationId,
},
select: {
transactionType: true,
quantity: true,
},
});
return transactions.reduce((total, transaction) => {
return total + (transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity);
}, 0);
}
async function nextShipmentNumber() { async function nextShipmentNumber() {
const next = (await prisma.shipment.count()) + 1; const next = (await prisma.shipment.count()) + 1;
return `SHP-${String(next).padStart(5, "0")}`; return `SHP-${String(next).padStart(5, "0")}`;
@@ -147,7 +270,7 @@ export async function listShipments(filters: { q?: string; status?: ShipmentStat
orderBy: [{ createdAt: "desc" }], orderBy: [{ createdAt: "desc" }],
}); });
return shipments.map((shipment) => mapShipment(shipment)); return shipments.map((shipment) => mapShipmentSummary(shipment));
} }
export async function getShipmentById(shipmentId: string) { export async function getShipmentById(shipmentId: string) {
@@ -161,12 +284,56 @@ export async function getShipmentById(shipmentId: string) {
name: true, name: true,
}, },
}, },
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
}, },
}, },
picks: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
}, },
}); });
return shipment ? mapShipment(shipment) : null; return shipment ? mapShipmentDetail(shipment) : null;
} }
export async function createShipment(payload: ShipmentInput, actorId?: string | null) { export async function createShipment(payload: ShipmentInput, actorId?: string | null) {
@@ -300,6 +467,126 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." }; return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
} }
export async function postShipmentPick(shipmentId: string, payload: ShipmentPickInput, actorId?: string | null) {
const shipment = await prisma.shipment.findUnique({
where: { id: shipmentId },
include: {
salesOrder: {
include: {
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
},
},
},
picks: {
select: {
salesOrderLineId: true,
quantity: true,
},
},
},
});
if (!shipment) {
return { ok: false as const, reason: "Shipment was not found." };
}
const line = shipment.salesOrder.lines.find((entry) => entry.id === payload.salesOrderLineId);
if (!line) {
return { ok: false as const, reason: "Shipment pick must target a line on the linked sales order." };
}
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
id: true,
warehouseId: true,
},
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
const pickedQuantity = shipment.picks
.filter((pick) => pick.salesOrderLineId === payload.salesOrderLineId)
.reduce((sum, pick) => sum + pick.quantity, 0);
const remainingQuantity = Math.max(line.quantity - pickedQuantity, 0);
if (payload.quantity > remainingQuantity) {
return { ok: false as const, reason: "Pick quantity exceeds the remaining unpicked sales-order quantity for this shipment line." };
}
const onHand = await getItemLocationOnHand(line.item.id, payload.warehouseId, payload.locationId);
if (onHand < payload.quantity) {
return { ok: false as const, reason: "Shipment pick would drive the selected stock location below zero on-hand." };
}
await prisma.$transaction(async (tx) => {
await tx.shipmentPick.create({
data: {
shipmentId,
salesOrderLineId: payload.salesOrderLineId,
itemId: line.item.id,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
quantity: payload.quantity,
notes: payload.notes,
createdById: actorId ?? null,
},
});
await tx.inventoryTransaction.create({
data: {
itemId: line.item.id,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
transactionType: "ISSUE",
quantity: payload.quantity,
reference: `${shipment.shipmentNumber} shipment pick`,
notes: payload.notes || `Shipment pick for ${shipment.shipmentNumber}`,
createdById: actorId ?? null,
},
});
if (shipment.status === "DRAFT") {
await tx.shipment.update({
where: { id: shipmentId },
data: {
status: "PICKING",
},
});
}
});
const detail = await getShipmentById(shipmentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipmentId,
action: "pick.posted",
summary: `Posted shipment pick for ${detail.shipmentNumber}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
salesOrderLineId: payload.salesOrderLineId,
itemId: line.item.id,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
quantity: payload.quantity,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
}
export async function getShipmentPackingSlipData(shipmentId: string): Promise<ShipmentPackingSlipData | null> { export async function getShipmentPackingSlipData(shipmentId: string): Promise<ShipmentPackingSlipData | null> {
const shipment = await getShipmentDocumentData(shipmentId); const shipment = await getShipmentDocumentData(shipmentId);

View File

@@ -1,5 +1,6 @@
import { createApp } from "./app.js"; import { createApp } from "./app.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { pruneOldAuthSessions } from "./lib/auth-sessions.js";
import { bootstrapAppData } from "./lib/bootstrap.js"; import { bootstrapAppData } from "./lib/bootstrap.js";
import { prisma } from "./lib/prisma.js"; import { prisma } from "./lib/prisma.js";
import { setLatestStartupReport } from "./lib/startup-state.js"; import { setLatestStartupReport } from "./lib/startup-state.js";
@@ -8,6 +9,7 @@ import { recordSupportLog } from "./lib/support-log.js";
async function start() { async function start() {
await bootstrapAppData(); await bootstrapAppData();
const prunedSessionCount = await pruneOldAuthSessions();
const startupReport = await assertStartupReadiness(); const startupReport = await assertStartupReadiness();
setLatestStartupReport(startupReport); setLatestStartupReport(startupReport);
@@ -21,6 +23,7 @@ async function start() {
passCount: startupReport.passCount, passCount: startupReport.passCount,
warnCount: startupReport.warnCount, warnCount: startupReport.warnCount,
failCount: startupReport.failCount, failCount: startupReport.failCount,
prunedSessionCount,
}, },
}); });

View File

@@ -4,9 +4,9 @@ declare global {
namespace Express { namespace Express {
interface Request { interface Request {
authUser?: AuthUser; authUser?: AuthUser;
authSessionId?: string;
} }
} }
} }
export {}; export {};

Some files were not shown because too many files have changed in this diff Show More