Compare commits

..

38 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
83 changed files with 8390 additions and 1395 deletions

View File

@@ -6,8 +6,47 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
### Added ### Added
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups - 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 - 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 - 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 - 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 - Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
@@ -47,8 +86,8 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- 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
@@ -64,6 +103,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
### 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 - 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 - 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 - Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
@@ -78,7 +118,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- JWT authentication now validates against persisted session records and inactive users lose access immediately instead of waiting for token expiry - 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

View File

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

View File

@@ -23,12 +23,13 @@ Current foundation scope includes:
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
- purchase-order revision history and revision comparison across commercial and receipt changes - 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, milestones, rollups, 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
@@ -80,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:
@@ -88,15 +89,36 @@ 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, 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, 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 - Detail/List UX: projects now surface milestone progress and linked execution rollups
@@ -104,13 +126,13 @@ Current interactions:
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 is now visible through linked PO, receipt, vendor, and outstanding-supply rollups, and should later expand into project-side purchasing actions - 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:
@@ -122,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:
@@ -296,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
@@ -391,7 +416,7 @@ Current follow-up direction:
## 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, purchasing, 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`.

View File

@@ -14,10 +14,11 @@ This file tracks work that still needs to be completed. Shipped phase history an
## Near-term priority order ## Near-term priority order
1. Deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement 1. Manufacturing costing and execution depth, including scrap/rework/yield tracking and variance visibility
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views 2. Finance expansion across AP disbursements, invoice matching, vendor payments, and project-level P&L
3. Dashboard KPI, alert, recent-activity, and exception-widget expansion 3. Workbench finite-capacity intelligence, including conflict handling, queue-slot guidance, and auto-rebalance recommendations
4. Longer-term session history and audit depth beyond the current review filtering and retention cleanup 4. Dashboard KPI, alert, recent-activity, and exception-widget expansion, especially for finance, manufacturing, and planning
5. Longer-term session history and audit depth beyond the current review filtering and retention cleanup
## Active roadmap ## Active roadmap
@@ -31,7 +32,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
- Expand `Dashboard` by modular panels rather than redesigning it into a different shell - 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 richer KPI widgets, alerts, recent-activity queues, and exception reporting
- Add deeper project, manufacturing, purchasing, shipping, and audit/system-health widgets - Add deeper project, manufacturing, purchasing, finance, shipping, and audit/system-health widgets
### CRM and master data ### CRM and master data
@@ -64,6 +65,15 @@ This file tracks work that still needs to be completed. Shipped phase history an
- Better totals breakdown visibility on list pages and detail pages - Better totals breakdown visibility on list pages and detail pages
- Faster document cloning and quote-to-order style conversions across document types - Faster document cloning and quote-to-order style conversions across document types
### Finance
- Expand from customer receipts into AP disbursements, invoice matching, and vendor-payment control
- Add project-level P&L, cash posture, and earned-value style rollups across sales, purchasing, manufacturing, and shipping
- Deepen manufacturing costing with crew rates, burden rules, and variance reporting instead of only the current labor/overhead assumptions
- Add payment-status workflow depth on sales orders and linked finance cues on purchasing, manufacturing, shipping, and project records
- 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
### Shipping and logistics ### Shipping and logistics
- Partial shipment workflow and split-shipment visibility - Partial shipment workflow and split-shipment visibility
@@ -76,11 +86,11 @@ This file tracks work that still needs to be completed. Shipped phase history an
- Project document hub for drawings, support files, correspondence, and revision references - Project document hub for drawings, support files, correspondence, and revision references
- Non-manufacturing work packages for long-running execution tracking - Non-manufacturing work packages for long-running execution tracking
- Deeper project-level cost, material, schedule, and delivery rollups beyond the current purchasing/readiness cockpit - 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
- 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 deeper cost, material, schedule, shipping, and action-oriented summary workflows - 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
@@ -90,7 +100,8 @@ This file tracks work that still needs to be completed. Shipped phase history an
- Work orders tied more explicitly to sales demand or internal build demand where appropriate - 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 - Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
- Material consumption depth, WIP tracking, and execution traceability - Material consumption depth, WIP tracking, and execution traceability
- Labor and machine-time capture for production execution - Deeper labor depth beyond the shipped operator assignment and timer-based labor capture, including crew-level staffing, labor approvals, and machine/runtime integration
- Planned-versus-actual material, labor, and overhead variance reporting shared with the finance module
- Manufacturing rollups for open work, blockers, shortages, and throughput - Manufacturing rollups for open work, blockers, shortages, and throughput
- Traveler/job packet output - Traveler/job packet output
- Partial completions and split-order execution visibility - Partial completions and split-order execution visibility
@@ -101,14 +112,16 @@ This file tracks work that still needs to be completed. Shipped phase history an
### Planning and scheduling ### Planning and scheduling
- 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
- 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
- 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

View File

@@ -28,16 +28,19 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Purchase orders with vendor lookup, item lines, totals, and quick status actions - Purchase orders with vendor lookup, item lines, totals, and quick status actions
- Purchase-order line selection restricted to inventory items flagged as purchasable - 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 - 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 - Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline
- Shipping shipment records linked to sales orders - 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 - Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
- Logistics attachments directly on shipment records - Logistics attachments directly on shipment records
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage - Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
- Project milestones and project-side milestone/work-order rollups - 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 cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility - 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 - Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments - Manufacturing foundation with work orders, project linkage, 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, and automatic work-order operation planning for gantt scheduling - 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 invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity - Vendor-detail purchasing visibility with recent purchase-order activity
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence - Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
@@ -54,7 +57,10 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Theme persistence fixes and denser responsive workspace layouts - Theme persistence fixes and denser responsive workspace layouts
- Startup brand-theme hydration so Company Settings colors and font persist correctly across refresh - 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 - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
- Live planning gantt timelines driven by project and manufacturing data - 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 - 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 - Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow - Docker image validated locally with successful app startup and login flow
@@ -73,6 +79,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Project records with customer linkage, status, owner, priority, due dates, milestones, and notes - 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 milestone status tracking and project-side milestone/work-order rollups
- Project-to-quote, sales-order, and shipment linkage for delivery context - 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 attachments through the shared file pipeline
- Project list/detail/create/edit flows and dashboard visibility - Project list/detail/create/edit flows and dashboard visibility
@@ -86,9 +93,10 @@ This file tracks roadmap phases, slices, and major foundations that have already
### Phase 7: Planning and scheduling ### Phase 7: Planning and scheduling
- Live gantt schedule backed by active projects and open manufacturing work orders - Live workbench schedule backed by active projects and open manufacturing work orders
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility - Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
- Planning exception queue for overdue or at-risk project/manufacturing schedule items - 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 ### Phase 8: Demand planning and supply generation
@@ -97,6 +105,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Netting against available stock, active reservations, open work orders, and open purchase orders - Netting against available stock, active reservations, open work orders, and open purchase orders
- Build and buy recommendations surfaced directly from the sales-order workflow - Build and buy recommendations surfaced directly from the sales-order workflow
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations - 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 - 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 - Preferred-vendor sourcing on inventory items for buy-side planning defaults
- Pegged work-order and purchase-order supply links back to originating sales demand - Pegged work-order and purchase-order supply links back to originating sales demand

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,19 +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-[22px] 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>
<h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1> <h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1>
</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"
}` }`
} }
@@ -209,12 +222,12 @@ export function AppShell() {
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="mt-auto space-y-3"> <div className="mt-auto space-y-2.5">
<div className="rounded-[18px] border border-line/70 bg-page/70 p-3"> <div className="rounded-[16px] border border-line/70 bg-page/70 p-2.5">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">Theme</p> <p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">Theme</p>
<ThemeToggle /> <ThemeToggle />
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/70 p-4"> <div className="rounded-[16px] border border-line/70 bg-page/70 p-3">
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p> <p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
<p className="text-xs text-muted">{user?.email}</p> <p className="text-xs text-muted">{user?.email}</p>
<button <button
@@ -222,7 +235,7 @@ export function AppShell() {
onClick={() => { onClick={() => {
void logout(); void logout();
}} }}
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page" className="mt-3 rounded-xl bg-text px-3 py-2 text-sm font-semibold text-page"
> >
Sign out Sign out
</button> </button>
@@ -230,13 +243,13 @@ export function AppShell() {
</div> </div>
</aside> </aside>
<main className="min-w-0 flex-1"> <main className="min-w-0 flex-1">
<nav className="mb-4 flex gap-3 overflow-x-auto rounded-[20px] border border-line/70 bg-surface/85 p-3 shadow-panel backdrop-blur md:hidden"> <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">
{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"
}` }`
} }
@@ -246,7 +259,7 @@ export function AppShell() {
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="mb-4 md:hidden"> <div className="mb-3 md:hidden">
<ThemeToggle /> <ThemeToggle />
</div> </div>
<Outlet /> <Outlet />

View File

@@ -11,6 +11,12 @@ interface ConfirmActionDialogProps {
intent?: "danger" | "primary"; intent?: "danger" | "primary";
confirmationLabel?: string; confirmationLabel?: string;
confirmationValue?: string; confirmationValue?: string;
extraFieldLabel?: string;
extraFieldPlaceholder?: string;
extraFieldValue?: string;
extraFieldRequired?: boolean;
extraFieldMultiline?: boolean;
onExtraFieldChange?: (value: string) => void;
isConfirming?: boolean; isConfirming?: boolean;
onConfirm: () => void | Promise<void>; onConfirm: () => void | Promise<void>;
onClose: () => void; onClose: () => void;
@@ -27,6 +33,12 @@ export function ConfirmActionDialog({
intent = "danger", intent = "danger",
confirmationLabel, confirmationLabel,
confirmationValue, confirmationValue,
extraFieldLabel,
extraFieldPlaceholder,
extraFieldValue = "",
extraFieldRequired = false,
extraFieldMultiline = false,
onExtraFieldChange,
isConfirming = false, isConfirming = false,
onConfirm, onConfirm,
onClose, onClose,
@@ -44,7 +56,11 @@ export function ConfirmActionDialog({
} }
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue); const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue); const requiresExtraField = Boolean(extraFieldLabel);
const isConfirmDisabled =
isConfirming ||
(requiresTypedConfirmation && typedValue.trim() !== confirmationValue) ||
(requiresExtraField && extraFieldRequired && extraFieldValue.trim().length === 0);
const confirmButtonClass = const confirmButtonClass =
intent === "danger" intent === "danger"
? "bg-red-600 text-white hover:bg-red-700" ? "bg-red-600 text-white hover:bg-red-700"
@@ -81,6 +97,27 @@ export function ConfirmActionDialog({
/> />
</label> </label>
) : null} ) : 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"> <div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button <button
type="button" type="button"

View File

@@ -100,7 +100,7 @@ function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): A
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) { function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
return ( return (
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4"> <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 className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<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>
@@ -111,7 +111,7 @@ function ComparisonCard({ label, document }: { label: string; document: Comparis
{document.status} {document.status}
</span> </span>
</div> </div>
<dl className="mt-4 grid gap-3 sm:grid-cols-2"> <dl className="mt-3 grid gap-2 sm:grid-cols-2">
{document.metaFields.map((field) => ( {document.metaFields.map((field) => (
<div key={`${label}-${field.label}`}> <div key={`${label}-${field.label}`}>
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt> <dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
@@ -119,15 +119,15 @@ function ComparisonCard({ label, document }: { label: string; document: Comparis
</div> </div>
))} ))}
</dl> </dl>
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-2 sm:grid-cols-2">
{document.totalFields.map((field) => ( {document.totalFields.map((field) => (
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <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="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 className="mt-1 text-sm font-semibold text-text">{field.value}</div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-4"> <div className="mt-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div> <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> <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
</div> </div>
@@ -164,11 +164,11 @@ export function DocumentRevisionComparison({
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields); const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
return ( return (
<section className="rounded-[20px] 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 xl:flex-row xl:items-start xl:justify-between"> <div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{title}</p> <p className="section-kicker">{title}</p>
<p className="mt-2 text-sm text-muted">{description}</p> <p className="mt-1 text-sm text-muted">{description}</p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label className="block min-w-[220px]"> <label className="block min-w-[220px]">
@@ -202,19 +202,19 @@ export function DocumentRevisionComparison({
</label> </label>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-3 xl:grid-cols-2"> <div className="mt-3 grid gap-3 xl:grid-cols-2">
<ComparisonCard label="Baseline" document={leftDocument} /> <ComparisonCard label="Baseline" document={leftDocument} />
<ComparisonCard label="Compare To" document={rightDocument} /> <ComparisonCard label="Compare To" document={rightDocument} />
</div> </div>
<div className="mt-5 grid gap-3 xl:grid-cols-2"> <div className="mt-3 grid gap-3 xl:grid-cols-2">
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4"> <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> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
{metaChanges.length === 0 && totalChanges.length === 0 ? ( {metaChanges.length === 0 && totalChanges.length === 0 ? (
<div className="mt-4 text-sm text-muted">No header or total changes between the selected revisions.</div> <div className="mt-3 text-sm text-muted">No header or total changes.</div>
) : ( ) : (
<div className="mt-4 space-y-3"> <div className="mt-3 space-y-2">
{[...metaChanges, ...totalChanges].map((change) => ( {[...metaChanges, ...totalChanges].map((change) => (
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <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="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
<div className="mt-2 text-sm text-text"> <div className="mt-2 text-sm text-text">
{change.leftValue} {"->"} {change.rightValue} {change.leftValue} {"->"} {change.rightValue}
@@ -224,28 +224,28 @@ export function DocumentRevisionComparison({
</div> </div>
)} )}
</article> </article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4"> <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> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
<div className="mt-4 grid gap-3 sm:grid-cols-3"> <div className="mt-3 grid gap-2 sm:grid-cols-3">
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-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="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 className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-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">Removed</div> <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 className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-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">Changed</div> <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 className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
</div> </div>
</div> </div>
{diffRows.length === 0 ? ( {diffRows.length === 0 ? (
<div className="mt-4 text-sm text-muted">No line-level changes between the selected revisions.</div> <div className="mt-3 text-sm text-muted">No line-level changes.</div>
) : ( ) : (
<div className="mt-4 space-y-3"> <div className="mt-3 space-y-2">
{diffRows.map((row) => ( {diffRows.map((row) => (
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <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="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div> <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> <span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>

View File

@@ -133,12 +133,12 @@ export function FileAttachmentsPanel({
} }
return ( return (
<article className="min-w-0 rounded-[20px] 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">
@@ -147,17 +147,17 @@ 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-[18px] 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-[18px] 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}
@@ -166,7 +166,7 @@ export function FileAttachmentsPanel({
<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">

View File

@@ -4,6 +4,36 @@
@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

@@ -14,6 +14,13 @@ import type {
ApiResponse, ApiResponse,
CompanyProfileDto, CompanyProfileDto,
CompanyProfileInput, CompanyProfileInput,
FinanceCapexDto,
FinanceCapexInput,
FinanceCustomerPaymentDto,
FinanceCustomerPaymentInput,
FinanceDashboardDto,
FinanceProfileDto,
FinanceProfileInput,
FileAttachmentDto, FileAttachmentDto,
PlanningTimelineDto, PlanningTimelineDto,
LoginRequest, LoginRequest,
@@ -61,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,
@@ -99,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";
@@ -280,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?: {
@@ -572,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);
}, },
@@ -605,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({
@@ -632,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
); );
}, },
@@ -802,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

@@ -101,8 +101,11 @@ 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(() => const LandingPage = React.lazy(() =>
import("./modules/landing/LandingPage").then((module) => ({ default: module.LandingPage })) import("./modules/landing/LandingPage").then((module) => ({ default: module.LandingPage }))
@@ -201,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: [
@@ -258,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 /> },
],
}, },
], ],
}, },

View File

@@ -58,12 +58,11 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
} }
return ( return (
<article className="min-w-0 rounded-[20px] 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-[18px] 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>
) : ( ) : (
@@ -72,7 +71,7 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
<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>
@@ -151,4 +150,3 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
</article> </article>
); );
} }

View File

@@ -111,21 +111,19 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[20px] 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-[20px] 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-[20px] 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,31 +216,30 @@ 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[20px] 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}
@@ -259,11 +256,10 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</section> </section>
) : null} ) : null}
{entity === "vendor" ? ( {entity === "vendor" ? (
<section className="rounded-[20px] 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-[18px] 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-[18px] 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-[20px] 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,15 +328,14 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div> </div>
</article> </article>
) : null} ) : null}
<article className="min-w-0 rounded-[20px] 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-[18px] 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-[18px] 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">

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-[20px] 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-[20px] 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"

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-[20px] 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-[18px] 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-[18px] 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>

View File

@@ -82,19 +82,16 @@ function StackedBar({
function DashboardCard({ function DashboardCard({
eyebrow, eyebrow,
title,
children, children,
className = "", className = "",
}: { }: {
eyebrow: string; eyebrow: string;
title: string;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {
return ( return (
<article className={`rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${className}`.trim()}> <article className={`surface-panel ${className}`.trim()}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p> <p className="section-kicker">{eyebrow}</p>
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
{children} {children}
</article> </article>
); );
@@ -290,30 +287,30 @@ export function DashboardPage() {
]; ];
return ( return (
<div className="space-y-4"> <div className="page-stack">
{error ? <div className="rounded-[18px] border border-amber-400/30 bg-amber-500/12 px-3 py-3 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null} {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}
<section className="grid gap-3 xl:grid-cols-6"> <section className="grid gap-3 xl:grid-cols-6">
{metricCards.map((card) => ( {metricCards.map((card) => (
<article key={card.label} className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article key={card.label} className="surface-panel-tight">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{card.label}</p> <p className="metric-kicker">{card.label}</p>
<div className="mt-2 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div> <div className="mt-1.5 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div>
<div className="mt-2 flex items-center gap-3"> <div className="mt-1.5 flex items-center gap-2.5">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-page/80"> <div className="h-2 flex-1 overflow-hidden rounded-full bg-page/80">
<div className={`h-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} /> <div className={`h-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} />
</div> </div>
<span className="text-xs text-muted">Live</span> <span className="text-xs text-muted">Live</span>
</div> </div>
{card.secondary ? <div className="mt-2 text-xs text-muted">{card.secondary}</div> : null} {card.secondary ? <div className="mt-1.5 text-xs text-muted">{card.secondary}</div> : null}
</article> </article>
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]"> <section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]">
<DashboardCard eyebrow="Commercial Surface" title="Revenue and document mix"> <DashboardCard eyebrow="COMMERCIAL">
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quotes</div> <div className="metric-kicker">Quotes</div>
<div className="mt-2 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div> <div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div>
<div className="mt-3 space-y-2"> <div className="mt-2.5 space-y-2">
<div className="flex items-center justify-between text-xs text-muted"> <div className="flex items-center justify-between text-xs text-muted">
<span>Draft</span> <span>Draft</span>
<span>{draftQuoteCount}</span> <span>{draftQuoteCount}</span>
@@ -326,10 +323,10 @@ export function DashboardPage() {
<ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" /> <ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" />
</div> </div>
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Orders</div> <div className="metric-kicker">Orders</div>
<div className="mt-2 text-2xl font-bold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</div> <div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2"> <div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
<div> <div>
<div className="text-xs text-muted">Issued / approved</div> <div className="text-xs text-muted">Issued / approved</div>
<div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div> <div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div>
@@ -339,14 +336,14 @@ export function DashboardPage() {
<div className="mt-1 text-lg font-semibold text-text">{orderCount}</div> <div className="mt-1 text-lg font-semibold text-text">{orderCount}</div>
</div> </div>
</div> </div>
<div className="mt-3"> <div className="mt-2.5">
<ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" /> <ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" />
</div> </div>
</div> </div>
</div> </div>
</DashboardCard> </DashboardCard>
<DashboardCard eyebrow="CRM Footprint" title="Customer and vendor balance"> <DashboardCard eyebrow="CRM">
<div className="mt-4 space-y-4"> <div className="mt-3 space-y-3">
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted">Active customers</span> <span className="text-muted">Active customers</span>
@@ -356,21 +353,21 @@ export function DashboardPage() {
<ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" /> <ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" />
</div> </div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-2.5 sm:grid-cols-3">
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Customers</div> <div className="metric-kicker">Customers</div>
<div className="mt-1 text-lg font-bold text-text">{customerCount}</div> <div className="mt-1 text-lg font-bold text-text">{customerCount}</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Resellers</div> <div className="metric-kicker">Resellers</div>
<div className="mt-1 text-lg font-bold text-text">{resellerCount}</div> <div className="mt-1 text-lg font-bold text-text">{resellerCount}</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Vendors</div> <div className="metric-kicker">Vendors</div>
<div className="mt-1 text-lg font-bold text-text">{vendorCount}</div> <div className="mt-1 text-lg font-bold text-text">{vendorCount}</div>
</div> </div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3"> <div className="surface-panel-tight">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted">Strategic accounts</span> <span className="text-muted">Strategic accounts</span>
<span className="font-semibold text-text">{strategicCustomerCount}</span> <span className="font-semibold text-text">{strategicCustomerCount}</span>
@@ -383,11 +380,11 @@ export function DashboardPage() {
</DashboardCard> </DashboardCard>
</section> </section>
<section className="grid gap-3 xl:grid-cols-3"> <section className="grid gap-3 xl:grid-cols-3">
<DashboardCard eyebrow="Inventory and Supply" title="Stock posture"> <DashboardCard eyebrow="INVENTORY">
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Item mix</div> <div className="metric-kicker">Item Mix</div>
<div className="mt-3 space-y-3"> <div className="mt-2.5 space-y-2.5">
<div> <div>
<div className="flex items-center justify-between text-xs text-muted"> <div className="flex items-center justify-between text-xs text-muted">
<span>Active items</span> <span>Active items</span>
@@ -417,14 +414,14 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Storage surface</div> <div className="metric-kicker">Storage</div>
<div className="mt-3 grid gap-3"> <div className="mt-2.5 grid gap-2.5">
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Warehouses</div> <div className="text-xs text-muted">Warehouses</div>
<div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div> <div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Locations</div> <div className="text-xs text-muted">Locations</div>
<div className="mt-1 text-lg font-bold text-text">{locationCount}</div> <div className="mt-1 text-lg font-bold text-text">{locationCount}</div>
</div> </div>
@@ -432,10 +429,10 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</DashboardCard> </DashboardCard>
<DashboardCard eyebrow="Supply Execution" title="Purchasing and manufacturing flow"> <DashboardCard eyebrow="SUPPLY">
<div className="mt-4 rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="mt-3 surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Open workload split</div> <div className="metric-kicker">Open Workload</div>
<div className="mt-3"> <div className="mt-2.5">
<StackedBar <StackedBar
segments={[ segments={[
{ value: openPurchaseOrderCount, tone: "bg-teal-500" }, { value: openPurchaseOrderCount, tone: "bg-teal-500" },
@@ -445,31 +442,31 @@ export function DashboardPage() {
]} ]}
/> />
</div> </div>
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Open PO queue</div> <div className="text-xs text-muted">Open PO queue</div>
<div className="mt-1 text-lg font-bold text-text">{openPurchaseOrderCount}</div> <div className="mt-1 text-lg font-bold text-text">{openPurchaseOrderCount}</div>
<div className="mt-1 text-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div> <div className="mt-1 text-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Active work orders</div> <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-lg font-bold text-text">{activeWorkOrderCount}</div>
<div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div> <div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Issued / approved POs</div> <div className="text-xs text-muted">Issued / approved POs</div>
<div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div> <div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div>
</div> </div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3"> <div className="surface-panel-tight bg-surface/80">
<div className="text-xs text-muted">Released WOs</div> <div className="text-xs text-muted">Released WOs</div>
<div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div> <div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div>
</div> </div>
</div> </div>
</div> </div>
</DashboardCard> </DashboardCard>
<DashboardCard eyebrow="Readiness" title="Planning pressure"> <DashboardCard eyebrow="READINESS">
<div className="mt-4 space-y-3"> <div className="mt-3 space-y-2.5">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted">Shortage items</span> <span className="text-muted">Shortage items</span>
<span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span> <span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span>
@@ -478,9 +475,9 @@ export function DashboardPage() {
<ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" /> <ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" />
</div> </div>
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Build vs buy</div> <div className="metric-kicker">Build Vs Buy</div>
<div className="mt-3"> <div className="mt-2.5">
<StackedBar <StackedBar
segments={[ segments={[
{ value: buildRecommendationCount, tone: "bg-indigo-500" }, { value: buildRecommendationCount, tone: "bg-indigo-500" },
@@ -488,7 +485,7 @@ export function DashboardPage() {
]} ]}
/> />
</div> </div>
<div className="mt-3 grid gap-3 sm:grid-cols-2"> <div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
<div> <div>
<div className="text-xs text-muted">Build recommendations</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 className="mt-1 text-lg font-bold text-text">{planningRollup ? buildRecommendationCount : "No access"}</div>
@@ -499,7 +496,7 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs text-muted">Uncovered quantity</div> <div className="text-xs text-muted">Uncovered quantity</div>
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? totalUncoveredQuantity : "No access"}</div> <div className="mt-1 text-lg font-bold text-text">{planningRollup ? totalUncoveredQuantity : "No access"}</div>
</div> </div>
@@ -507,11 +504,11 @@ export function DashboardPage() {
</DashboardCard> </DashboardCard>
</section> </section>
<section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]"> <section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]">
<DashboardCard eyebrow="Programs" title="Project and shipment execution"> <DashboardCard eyebrow="PROGRAMS">
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-2.5 sm:grid-cols-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Projects</div> <div className="metric-kicker">Projects</div>
<div className="mt-3"> <div className="mt-2.5">
<StackedBar <StackedBar
segments={[ segments={[
{ value: activeProjectCount, tone: "bg-violet-500" }, { value: activeProjectCount, tone: "bg-violet-500" },
@@ -520,7 +517,7 @@ export function DashboardPage() {
]} ]}
/> />
</div> </div>
<div className="mt-4 grid gap-3 sm:grid-cols-3"> <div className="mt-3 grid gap-2.5 sm:grid-cols-3">
<div> <div>
<div className="text-xs text-muted">Active</div> <div className="text-xs text-muted">Active</div>
<div className="mt-1 font-semibold text-text">{activeProjectCount}</div> <div className="mt-1 font-semibold text-text">{activeProjectCount}</div>
@@ -535,9 +532,9 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="surface-panel-tight">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipping</div> <div className="metric-kicker">Shipping</div>
<div className="mt-3"> <div className="mt-2.5">
<StackedBar <StackedBar
segments={[ segments={[
{ value: activeShipmentCount, tone: "bg-brand" }, { value: activeShipmentCount, tone: "bg-brand" },
@@ -546,7 +543,7 @@ export function DashboardPage() {
]} ]}
/> />
</div> </div>
<div className="mt-4 grid gap-3 sm:grid-cols-3"> <div className="mt-3 grid gap-2.5 sm:grid-cols-3">
<div> <div>
<div className="text-xs text-muted">Open</div> <div className="text-xs text-muted">Open</div>
<div className="mt-1 font-semibold text-text">{activeShipmentCount}</div> <div className="mt-1 font-semibold text-text">{activeShipmentCount}</div>
@@ -563,8 +560,8 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</DashboardCard> </DashboardCard>
<DashboardCard eyebrow="Operations Mix" title="Cross-module volume"> <DashboardCard eyebrow="OPERATIONS">
<div className="mt-4 space-y-3"> <div className="mt-3 space-y-2.5">
{[ {[
{ label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" }, { label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" },
{ label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" }, { label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" },
@@ -574,7 +571,7 @@ export function DashboardPage() {
{ label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" }, { label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" },
{ label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" }, { label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" },
].map((row) => ( ].map((row) => (
<div key={row.label} className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div key={row.label} className="surface-panel-tight">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted">{row.label}</span> <span className="text-muted">{row.label}</span>
<span className="font-semibold text-text">{row.value}</span> <span className="font-semibold text-text">{row.value}</span>
@@ -585,7 +582,7 @@ export function DashboardPage() {
</div> </div>
))} ))}
</div> </div>
{snapshot ? <div className="mt-4 text-xs text-muted">Refreshed {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null} {snapshot ? <div className="mt-3 text-xs text-muted">REFRESHED {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null}
</DashboardCard> </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,196 +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 ?? [];
const ganttCellHeight = 44;
const ganttScaleHeight = 56;
const ganttHeight = Math.max(420, tasks.length * ganttCellHeight + ganttScaleHeight);
return (
<section className="space-y-4">
<div className="rounded-[20px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-x-auto overflow-y-visible rounded-[20px] 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>
<div style={{ height: `${ganttHeight}px`, minWidth: "100%" }}>
<Gantt
tasks={tasks.map((task: GanttTaskDto) => ({
...task,
start: new Date(task.start),
end: new Date(task.end),
parent: task.parentId ?? undefined,
}))}
links={links}
cellHeight={ganttCellHeight}
scaleHeight={ganttScaleHeight}
/>
</div>
</div>
<aside className="space-y-3">
<section className="rounded-[20px] 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-[18px] 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-[18px] 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-[20px] 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-[18px] 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

@@ -308,18 +308,18 @@ export function InventoryDetailPage() {
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[20px] 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">
@@ -334,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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[20px] 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>
@@ -397,9 +397,9 @@ export function InventoryDetailPage() {
</div> </div>
</dl> </dl>
</article> </article>
<article className="rounded-[20px] 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">Thumbnail</p> <p className="section-kicker">THUMBNAIL</p>
<div className="mt-4 overflow-hidden rounded-[18px] border border-line/70 bg-page/70"> <div className="mt-3 overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
{thumbnailPreviewUrl ? ( {thumbnailPreviewUrl ? (
<img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" /> <img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" />
) : ( ) : (
@@ -408,19 +408,19 @@ export function InventoryDetailPage() {
</div> </div>
)} )}
</div> </div>
<div className="mt-3 text-xs text-muted"> <div className="mt-2 text-xs text-muted">
{thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."} {thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."}
</div> </div>
</article> </article>
</div> </div>
<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-[20px] 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">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">
@@ -444,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-[20px] 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>
@@ -496,14 +496,14 @@ export function InventoryDetailPage() {
</div> </div>
</form> </form>
) : null} ) : null}
<article className="rounded-[20px] 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-[18px] 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-[18px] 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">
@@ -535,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-[20px] 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" />
@@ -588,9 +588,9 @@ export function InventoryDetailPage() {
</div> </div>
</div> </div>
</form> </form>
<form className="rounded-[20px] 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" />
@@ -629,14 +629,14 @@ 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-[20px] 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-[18px] 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-[18px] 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">
@@ -655,14 +655,14 @@ export function InventoryDetailPage() {
</div> </div>
)} )}
</article> </article>
<article className="rounded-[20px] 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-[18px] 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-[18px] 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">

View File

@@ -11,7 +11,7 @@ import type {
} from "@mrp/shared/dist/inventory/types.js"; } 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 { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
@@ -444,41 +444,52 @@ 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-[20px] 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">
Define item master data and the first revision of the bill of materials for assemblies and manufactured items.
</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link <button
to="/inventory/sku-master" 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" 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 SKU master
</Link> </button>
<Link <button
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`} 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" 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>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[20px] 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">
<div className="block 2xl:col-span-2"> <div className="block 2xl:col-span-2">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<span className="block text-sm font-semibold text-text">SKU builder</span> <span className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">SKU BUILDER</span>
<Link to="/inventory/sku-master" className="text-xs font-semibold text-brand"> <button type="button" onClick={openSkuMaster} className="text-xs font-semibold text-brand">
Manage SKU tree Manage SKU tree
</Link> </button>
</div> </div>
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3"> <div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3">
<label className="block"> <label className="block">
@@ -593,7 +604,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</div> </div>
</div> </div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"> <div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-sm font-semibold text-text">Thumbnail attachment</div> <div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Thumbnail attachment</div>
<div className="mt-2 text-sm text-muted"> <div className="mt-2 text-sm text-muted">
{pendingThumbnailFile {pendingThumbnailFile
? `${pendingThumbnailFile.name} will upload when you save this item.` ? `${pendingThumbnailFile.name} will upload when you save this item.`
@@ -603,9 +614,6 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.` ? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.`
: "Attach a product image, render, or reference photo for this item."} : "Attach a product image, render, or reference photo for this item."}
</div> </div>
<div className="mt-3 text-xs text-muted">
Supported by the existing file-attachment system. The thumbnail is stored separately from general item documents so the item editor can treat it as the primary visual.
</div>
</div> </div>
</div> </div>
<div className="grid gap-3 xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-4">
@@ -654,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
@@ -662,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>
@@ -733,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">
@@ -756,23 +764,22 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</label> </label>
</section> </section>
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? ( {form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
<section className="rounded-[20px] 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-[18px] 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-[18px] 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]">
@@ -823,12 +830,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
)} )}
</section> </section>
) : null} ) : null}
<section className="rounded-[20px] 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"
@@ -839,11 +845,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</button> </button>
</div> </div>
{form.bomLines.length === 0 ? ( {form.bomLines.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"> <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-[18px] 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]">
@@ -974,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"

View File

@@ -41,14 +41,11 @@ export function InventoryListPage() {
}, [searchTerm, statusFilter, token, typeFilter]); }, [searchTerm, statusFilter, token, typeFilter]);
return ( return (
<section className="rounded-[20px] 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 ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -61,7 +58,7 @@ export function InventoryListPage() {
</div> </div>
) : null} ) : null}
</div> </div>
<div className="mt-6 grid gap-3 rounded-[18px] 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
@@ -100,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-[18px] 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>

View File

@@ -87,7 +87,7 @@ export function InventorySkuMasterPage() {
return ( return (
<div key={node.id} className="space-y-2"> <div key={node.id} className="space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3" style={{ marginLeft: `${depth * 16}px` }}> <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="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -107,8 +107,10 @@ export function InventorySkuMasterPage() {
</span> </span>
)} )}
<div className="min-w-0"> <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="text-sm font-semibold text-text">
<div className="mt-1 text-xs text-muted">Level {node.level} {node.childCount} child branch(es)</div> {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> </div>
</div> </div>
@@ -193,13 +195,12 @@ export function InventorySkuMasterPage() {
} }
return ( return (
<section className="space-y-6"> <section className="page-stack">
<div className="rounded-[20px] 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 Master Data</p> <p className="section-kicker">INVENTORY MASTER DATA</p>
<h3 className="mt-2 text-xl font-bold text-text">SKU Master Builder</h3> <h3 className="module-title">SKU MASTER BUILDER</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Define family roots, branch-specific child codes, and the family-scoped short-code suffix that finishes each generated SKU.</p>
</div> </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"> <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 Back to items
@@ -207,13 +208,13 @@ export function InventorySkuMasterPage() {
</div> </div>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.5fr]"> <div className="grid gap-3 xl:grid-cols-[0.9fr_1.5fr]">
<div className="space-y-6"> <div className="space-y-3">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="text-sm font-semibold text-text">Families</div> <p className="section-kicker">FAMILIES</p>
<div className="mt-4 space-y-2"> <div className="mt-3 space-y-2">
{catalog.families.length === 0 ? ( {catalog.families.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">No SKU families defined yet.</div> <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) => ( catalog.families.map((family) => (
<button <button
@@ -224,13 +225,18 @@ export function InventorySkuMasterPage() {
setExpandedNodeIds([]); setExpandedNodeIds([]);
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null })); setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
}} }}
className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${ 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" 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="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-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> <div className="mt-2 text-xs text-muted">
{family.childNodeCount} branch nodes - next {family.sequenceCode}
{String(family.nextSequenceNumber).padStart(4, "0")}
</div>
</button> </button>
)) ))
)} )}
@@ -238,9 +244,9 @@ export function InventorySkuMasterPage() {
</section> </section>
{canManage ? ( {canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="text-sm font-semibold text-text">Add family</div> <p className="section-kicker">ADD FAMILY</p>
<form className="mt-4 space-y-3" onSubmit={handleCreateFamily}> <form className="mt-3 space-y-3" onSubmit={handleCreateFamily}>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
@@ -265,26 +271,26 @@ export function InventorySkuMasterPage() {
) : null} ) : null}
</div> </div>
<div className="space-y-6"> <div className="space-y-3">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="text-sm font-semibold text-text">Branch tree</div> <p className="section-kicker">BRANCH TREE</p>
<div className="mt-1 text-xs text-muted">{status}</div> <div className="mt-1 text-xs text-muted">{status}</div>
</div> </div>
{selectedFamilyId ? ( {selectedFamilyId ? (
<div className="text-xs text-muted">Up to 6 total SKU levels including family root.</div> <div className="text-xs text-muted">Up to 6 total SKU levels.</div>
) : null} ) : null}
</div> </div>
<div className="mt-4 space-y-3"> <div className="mt-3 space-y-2">
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>} {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> </div>
</section> </section>
{canManage ? ( {canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="surface-panel">
<div className="text-sm font-semibold text-text">Add branch node</div> <p className="section-kicker">ADD BRANCH NODE</p>
<form className="mt-4 space-y-3" onSubmit={handleCreateNode}> <form className="mt-3 space-y-3" onSubmit={handleCreateNode}>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>

View File

@@ -32,25 +32,25 @@ export function WarehouseDetailPage() {
}, [token, warehouseId]); }, [token, warehouseId]);
if (!warehouse) { if (!warehouse) {
return <div className="rounded-[20px] 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-[20px] 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-[20px] 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-[20px] 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-[18px] 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-[18px] 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>

View File

@@ -92,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-[20px] 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}`}
@@ -107,40 +107,39 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
</Link> </Link>
</div> </div>
</section> </section>
<section className="space-y-4 rounded-[20px] 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-[18px] 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-[20px] 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-[18px] 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-[18px] 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>
@@ -156,7 +155,7 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
</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>
@@ -164,7 +163,7 @@ 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"}

View File

@@ -31,12 +31,11 @@ export function WarehousesPage() {
}, [token]); }, [token]);
return ( return (
<section className="rounded-[20px] 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-[18px] 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>

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-[20px] 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-[18px] 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-[18px] 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,23 +115,63 @@ export function ManufacturingPage() {
)} )}
</article> </article>
{canManage ? ( {canManage ? (
<form onSubmit={handleSubmit} className="rounded-[20px] 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>
<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" /> <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">
@@ -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>

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";
@@ -16,12 +27,23 @@ 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< const [pendingConfirmation, setPendingConfirmation] = useState<
| { | {
kind: "status" | "issue" | "completion"; kind: "status" | "issue" | "completion";
@@ -56,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) => {
@@ -64,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(
@@ -79,8 +122,12 @@ 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);
setHoldReasonDraft("");
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing."); 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.";
@@ -137,6 +184,147 @@ 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) { function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!workOrder) { if (!workOrder) {
return; return;
@@ -149,6 +337,8 @@ export function WorkOrderDetailPage() {
impact: impact:
nextStatus === "CANCELLED" nextStatus === "CANCELLED"
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations." ? "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" : nextStatus === "COMPLETE"
? "Completing the work order signals execution closure and can change readiness views across the system." ? "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.", : "This changes the execution state used by planning, dashboards, and downstream operational review.",
@@ -158,6 +348,7 @@ export function WorkOrderDetailPage() {
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined, confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
nextStatus, nextStatus,
}); });
setHoldReasonDraft(nextStatus === "ON_HOLD" ? workOrder.holdReason ?? "" : "");
} }
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) { function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
@@ -200,14 +391,20 @@ export function WorkOrderDetailPage() {
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[20px] 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>
@@ -219,11 +416,10 @@ export function WorkOrderDetailPage() {
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[20px] 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) => (
@@ -235,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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[20px] 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>
@@ -255,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-[20px] 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-[20px] 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-[18px] 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-[18px] 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">
@@ -284,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>
@@ -296,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-[20px] 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) => (
@@ -310,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>
@@ -318,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) => (
@@ -327,12 +678,12 @@ 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-[18px] 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">
@@ -340,15 +691,15 @@ export function WorkOrderDetailPage() {
</button> </button>
</div> </div>
</form> </form>
<form onSubmit={handleCompletionSubmit} className="rounded-[20px] 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-[18px] 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>
@@ -359,12 +710,12 @@ export function WorkOrderDetailPage() {
</form> </form>
</section> </section>
) : null} ) : null}
<section className="rounded-[20px] 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-[18px] 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-[18px] 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">
@@ -395,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-[20px] 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-[18px] 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-[18px] 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>
@@ -417,14 +768,14 @@ export function WorkOrderDetailPage() {
</div> </div>
)} )}
</article> </article>
<article className="rounded-[20px] 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-[18px] 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-[18px] 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>
@@ -455,6 +806,12 @@ export function WorkOrderDetailPage() {
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"} confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel} confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue} 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={ isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) || (pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "issue" && isPostingIssue) || (pendingConfirmation?.kind === "issue" && isPostingIssue) ||

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-[20px] 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-[20px] 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>
@@ -294,7 +297,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
<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-[18px] 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"}

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-[20px] 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-[20px] 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,7 +62,7 @@ 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-[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="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>

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { WorkOrderSummaryDto } 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 { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -23,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;
@@ -50,7 +51,7 @@ export function ProjectDetailPage() {
}, [projectId, token]); }, [projectId, token]);
if (!project) { if (!project) {
return <div className="rounded-[20px] 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) => { const sortedMilestones = [...project.milestones].sort((left, right) => {
@@ -95,6 +96,8 @@ export function ProjectDetailPage() {
const materialExceptionItems = planning const materialExceptionItems = planning
? planning.items.filter((item) => item.uncoveredQuantity > 0 || item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0).slice(0, 5) ? 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 const completionPercent = project.rollups.milestoneCount > 0
? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100) ? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100)
: 0; : 0;
@@ -105,15 +108,59 @@ export function ProjectDetailPage() {
? "text-amber-600 dark:text-amber-300" ? "text-amber-600 dark:text-amber-300"
: "text-rose-600 dark:text-rose-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-[20px] 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>
@@ -125,47 +172,45 @@ 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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>
<section className="grid gap-3 xl:grid-cols-4"> <section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[18px] 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">Milestones</p><div className="mt-2 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">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="rounded-[18px] 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 Milestones</p><div className="mt-2 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">Overdue Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div></article>
<article className="rounded-[18px] 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">Linked Work Orders</p><div className="mt-2 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">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="rounded-[18px] 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 Orders</p><div className="mt-2 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</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>
<section className="rounded-[20px] 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">Project Cockpit</p> <p className="section-kicker">PROJECT COCKPIT</p>
<h4 className="mt-2 text-lg font-bold text-text">Cross-functional execution view</h4>
<p className="mt-2 text-sm text-muted">Commercial, supply, execution, purchasing, and delivery signals for this program in one place.</p>
</div> </div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-right"> <div className="surface-panel-tight text-right">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone Progress</div> <div className="metric-kicker">Milestone Progress</div>
<div className="mt-1 text-2xl font-bold text-text">{completionPercent}%</div> <div className="mt-1 text-2xl font-bold text-text">{completionPercent}%</div>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-3 xl:grid-cols-4"> <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">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">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">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> <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>
<div className="mt-5 grid gap-3 xl:grid-cols-3"> <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">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">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> <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>
<div className="mt-5 grid gap-3 xl:grid-cols-4"> <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">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">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">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> <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>
<div className="mt-5 grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"> <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"> <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> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Next Checkpoints</p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
@@ -180,81 +225,164 @@ export function ProjectDetailPage() {
</div> </div>
</section> </section>
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]"> <section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="surface-panel">
<div className="flex items-center justify-between gap-3"><div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Linked Purchasing</p><p className="mt-2 text-sm text-muted">Purchase orders and receipts tied back to the project sales order.</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> <div className="flex items-center justify-between gap-3">
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-6 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-6 space-y-3">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.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-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>} <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>
<article className="rounded-[20px] 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">Readiness Drivers</p> <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>
<div className="mt-5 space-y-3"> {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>}
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"><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> </article>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm text-text">Blocked milestones: <span className="font-semibold">{project.cockpit.risk.blockedMilestoneCount}</span></div> <article className="surface-panel">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm text-text">Overdue execution items: <span className="font-semibold">{project.cockpit.risk.overdueMilestoneCount + project.cockpit.risk.overdueWorkOrderCount}</span></div> <p className="section-kicker">READINESS DRIVERS</p>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm text-text">Uncovered material quantity: <span className="font-semibold">{project.cockpit.risk.totalUncoveredQuantity}</span></div> <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> </div>
</article> </article>
</section> </section>
<section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]"> <section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="rounded-[20px] 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 Exposure</p> <p className="section-kicker">VENDOR EXPOSURE</p>
{project.cockpit.purchasing.vendors.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No supplier exposure exists until purchasing is linked.</div> : <div className="mt-5 space-y-3">{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (<div key={vendor.vendorId} className="rounded-[18px] 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">{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>} {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>
<article className="rounded-[20px] 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 Receipts</p> <p className="section-kicker">RECENT RECEIPTS</p>
{project.cockpit.purchasing.recentReceipts.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase receipts have been posted against linked project supply.</div> : <div className="mt-5 space-y-3">{project.cockpit.purchasing.recentReceipts.map((receipt) => (<div key={receipt.receiptId} 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">{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>} {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> </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-[20px] 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-[20px] 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-[20px] 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="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="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="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">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 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="rounded-[20px] 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><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Milestones</p><p className="mt-2 text-sm text-muted">Track project checkpoints, blockers, and completion progress.</p></div> <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} {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> </div>
{project.milestones.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-6 space-y-3">{project.milestones.map((milestone) => (<div key={milestone.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="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></div></div>))}</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> </section>
{planning ? ( {planning ? (
<section className="rounded-[20px] 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-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div></article> <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>
<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">Buy Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div></article> <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>
<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">Uncovered Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div></article> <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 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">Shortage Items</p><div className="mt-2 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div></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>
</div> </div>
<div className="mt-5 space-y-3"> <div className="mt-3 space-y-2">
{planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => ( {planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => (
<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><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 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>
))} ))}
</div> </div>
</section> </section>
) : null} ) : null}
<section className="rounded-[20px] 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><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Links</p><p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p></div> <div><p className="section-kicker">MANUFACTURING LINKS</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} {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 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-6 space-y-3">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[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><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>} {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>}
</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-3 space-y-2">
{project.timeline.map((entry) => (
<div key={entry.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="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</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>
))}
</div>
)}
</section> </section>
<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." /> <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." />
<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>

View File

@@ -235,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-[20px] 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-[20px] 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>

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-[20px] 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-[20px] 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,11 +75,11 @@ 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-[18px] 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>

View File

@@ -292,11 +292,11 @@ export function PurchaseDetailPage() {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<div className="rounded-[20px] 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} />
@@ -326,11 +326,10 @@ export function PurchaseDetailPage() {
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[20px] 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) => (
@@ -342,33 +341,32 @@ 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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="rounded-[20px] 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 purchase order changes or receipts are posted.</p>
</div> </div>
</div> </div>
{activeDocument.revisions.length === 0 ? ( {activeDocument.revisions.length === 0 ? (
<div className="mt-6 rounded-[18px] 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-[18px] 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">
@@ -419,26 +417,37 @@ export function PurchaseDetailPage() {
/> />
) : null} ) : 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-[20px] 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-[20px] 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-[20px] 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-[18px] 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-[18px] 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">
@@ -455,12 +464,12 @@ export function PurchaseDetailPage() {
</div> </div>
)} )}
</section> </section>
<section className="rounded-[20px] 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-[18px] 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>
@@ -488,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-[20px] 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-[18px] 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>
@@ -569,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"
@@ -583,15 +590,14 @@ export function PurchaseDetailPage() {
)} )}
</article> </article>
) : null} ) : null}
<article className="rounded-[20px] 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-[18px] 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-[18px] 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">

View File

@@ -1,6 +1,6 @@
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 { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
@@ -14,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);
@@ -57,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;
@@ -103,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,
})); }));
@@ -124,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) {
@@ -135,6 +145,7 @@ 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,
@@ -252,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-[20px] 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-[20px] 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>
@@ -341,6 +356,14 @@ 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-[18px] 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" />
@@ -367,18 +390,18 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
</label> </label>
</div> </div>
</section> </section>
<section className="rounded-[20px] 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-[18px] 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-[18px] 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]">
@@ -454,13 +477,13 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
))} ))}
</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"}

View File

@@ -34,12 +34,11 @@ export function PurchaseListPage() {
}, [searchTerm, statusFilter, token]); }, [searchTerm, statusFilter, token]);
return ( return (
<section className="rounded-[20px] 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-[18px] 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-[18px] 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>

View File

@@ -22,6 +22,7 @@ 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,

View File

@@ -173,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()}`;
} }
@@ -186,6 +189,15 @@ 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()}`;
} }
@@ -322,12 +334,12 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
} }
return ( return (
<section className="space-y-4"> <section className="page-stack">
<div className="rounded-[20px] 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} />
@@ -384,11 +396,10 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[20px] 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) => (
@@ -406,58 +417,57 @@ 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[20px] 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-[18px] 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-[18px] 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">
@@ -507,8 +517,8 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
/> />
) : null} ) : 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-[20px] 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>
@@ -520,19 +530,30 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div> </div>
</dl> </dl>
</article> </article>
<article className="rounded-[20px] 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-[20px] 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-[18px] 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>
@@ -564,37 +585,33 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
)} )}
</section> </section>
{entity === "order" && planning ? ( {entity === "order" && planning ? (
<section className="rounded-[20px] 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-[18px] 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-[18px] 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-[18px] 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-[18px] 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>
@@ -665,7 +682,7 @@ 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-[18px] 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">
@@ -683,11 +700,10 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</section> </section>
) : null} ) : null}
{entity === "order" && canReadShipping ? ( {entity === "order" && canReadShipping ? (
<section className="rounded-[20px] 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">
@@ -696,11 +712,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
) : null} ) : null}
</div> </div>
{shipments.length === 0 ? ( {shipments.length === 0 ? (
<div className="mt-6 rounded-[18px] 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-[18px] 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">

View File

@@ -1,7 +1,7 @@
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 { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
@@ -167,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-[20px] 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-[20px] 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>
@@ -351,22 +355,22 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
</label> </label>
</div> </div>
</section> </section>
<section className="rounded-[20px] 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-[18px] 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-[18px] 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]">
@@ -451,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>
@@ -469,7 +473,7 @@ 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"}

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-[20px] 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-[18px] 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-[18px] 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>

View File

@@ -74,7 +74,7 @@ export function AdminDiagnosticsPage() {
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]); }, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
if (!diagnostics || !backupGuidance) { if (!diagnostics || !backupGuidance) {
return <div className="rounded-[20px] 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() {
@@ -156,15 +156,12 @@ export function AdminDiagnosticsPage() {
]; ];
return ( return (
<div className="space-y-6"> <div className="page-stack">
<section className="rounded-[20px] 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
@@ -189,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-[18px] 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-[20px] 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>
@@ -226,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>
@@ -238,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>
@@ -251,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>
@@ -266,17 +259,16 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[20px] 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">
@@ -287,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>
@@ -297,9 +289,9 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[20px] 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>
@@ -309,19 +301,18 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[20px] 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"> <p className="text-sm text-muted">
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"} {supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
</p> </p>
</div> </div>
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5"> <div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<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-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input <input
value={supportLogQuery} value={supportLogQuery}
onChange={(event) => setSupportLogQuery(event.target.value)} onChange={(event) => setSupportLogQuery(event.target.value)}
@@ -330,7 +321,7 @@ export function AdminDiagnosticsPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Level</span> <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"> <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="ALL">All levels</option>
<option value="ERROR">Error</option> <option value="ERROR">Error</option>
@@ -339,7 +330,7 @@ export function AdminDiagnosticsPage() {
</select> </select>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Source</span> <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"> <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> <option value="ALL">All sources</option>
{supportLogSources.map((source) => ( {supportLogSources.map((source) => (
@@ -348,7 +339,7 @@ export function AdminDiagnosticsPage() {
</select> </select>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Window</span> <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"> <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="ALL">All retained</option>
<option value="1">Last 24 hours</option> <option value="1">Last 24 hours</option>
@@ -356,13 +347,13 @@ export function AdminDiagnosticsPage() {
<option value="14">Last 14 days</option> <option value="14">Last 14 days</option>
</select> </select>
</label> </label>
<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>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div> <div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div> <div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div> <div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
</div> </div>
</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">
@@ -404,15 +395,14 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[20px] 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">

View File

@@ -93,7 +93,7 @@ export function CompanySettingsPage() {
}, [logoUrl]); }, [logoUrl]);
if (!form || !token) { if (!form || !token) {
return <div className="rounded-[20px] 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-[20px] 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-[20px] 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-[18px] 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-[20px] 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

View File

@@ -272,15 +272,12 @@ export function UserManagementPage() {
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length; const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
return ( return (
<div className="space-y-6"> <div className="page-stack">
<section className="rounded-[20px] 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">
@@ -293,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-[20px] 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}
@@ -314,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 }))}
@@ -324,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 ?? ""}
@@ -334,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 }))}
@@ -342,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 }))}
@@ -351,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}
@@ -360,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)}
@@ -379,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"}
@@ -387,11 +383,10 @@ export function UserManagementPage() {
</div> </div>
</form> </form>
<form className="rounded-[20px] 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}
@@ -407,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 }))}
@@ -417,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 }))}
@@ -427,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)}
@@ -446,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>
@@ -456,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"}
@@ -465,18 +460,14 @@ export function UserManagementPage() {
</form> </form>
</section> </section>
<section className="rounded-[20px] 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">Sessions</p> <p className="section-kicker">SESSIONS</p>
<h3 className="mt-2 text-lg font-bold text-text">Active sign-ins and revocation control</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Review recent authenticated sessions, see their current state, and revoke stale or risky access without changing the user record.
</p>
</div> </div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<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-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input <input
value={sessionQuery} value={sessionQuery}
onChange={(event) => setSessionQuery(event.target.value)} onChange={(event) => setSessionQuery(event.target.value)}
@@ -485,7 +476,7 @@ export function UserManagementPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">User</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">User</span>
<select <select
value={sessionUserFilter} value={sessionUserFilter}
onChange={(event) => setSessionUserFilter(event.target.value)} onChange={(event) => setSessionUserFilter(event.target.value)}
@@ -500,7 +491,7 @@ export function UserManagementPage() {
</select> </select>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select <select
value={sessionStatusFilter} value={sessionStatusFilter}
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])} onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
@@ -513,7 +504,7 @@ export function UserManagementPage() {
</select> </select>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Review</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Review</span>
<select <select
value={sessionReviewFilter} value={sessionReviewFilter}
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])} onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}

View File

@@ -1,22 +1,56 @@
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 { 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< const [pendingConfirmation, setPendingConfirmation] = useState<
| { | {
@@ -34,23 +68,38 @@ export function ShipmentDetailPage() {
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))) const selectedLine = shipment?.lines.find((line) => line.salesOrderLineId === pickForm.salesOrderLineId) ?? null;
.catch((error: unknown) => { const availableLocations = locationOptions.filter((location) => !pickForm.warehouseId || location.warehouseId === pickForm.warehouseId);
const message = error instanceof ApiError ? error.message : "Unable to load shipment."; const warehouseOptions = Array.from(
setStatus(message); new Map(locationOptions.map((location) => [location.warehouseId, { id: location.warehouseId, label: `${location.warehouseCode} · ${location.warehouseName}` }])).values()
}); );
}, [shipmentId, token]); 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) { async function applyStatusChange(nextStatus: ShipmentStatus) {
if (!token || !shipment) { if (!token || !shipment) {
@@ -62,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. Verify carrier paperwork and sales-order expectations if the shipment moved into a terminal state."); 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);
@@ -82,11 +132,11 @@ export function ShipmentDetailPage() {
description: `Update shipment ${shipment.shipmentNumber} from ${shipment.status} to ${nextStatus}.`, description: `Update shipment ${shipment.shipmentNumber} from ${shipment.status} to ${nextStatus}.`,
impact: impact:
nextStatus === "DELIVERED" nextStatus === "DELIVERED"
? "This marks delivery complete and can affect customer communication and project/shipping readiness views." ? "This marks delivery complete and can affect customer communication, project delivery status, and shipment closeout review."
: nextStatus === "SHIPPED" : nextStatus === "SHIPPED"
? "This marks the shipment as outbound and can trigger customer-facing tracking and downstream delivery expectations." ? "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.", : "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 the linked sales order still reflects reality.", 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}`, confirmLabel: `Set ${label}`,
confirmationLabel: nextStatus === "DELIVERED" ? "Type shipment number to confirm:" : undefined, confirmationLabel: nextStatus === "DELIVERED" ? "Type shipment number to confirm:" : undefined,
confirmationValue: nextStatus === "DELIVERED" ? shipment.shipmentNumber : undefined, confirmationValue: nextStatus === "DELIVERED" ? shipment.shipmentNumber : undefined,
@@ -139,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-[20px] 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-[20px] 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>
@@ -171,12 +247,12 @@ export function ShipmentDetailPage() {
</div> </div>
</div> </div>
</div> </div>
{canManage ? ( {canManage ? (
<section className="rounded-[20px] 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) => (
@@ -188,46 +264,263 @@ export function ShipmentDetailPage() {
</div> </div>
</section> </section>
) : null} ) : null}
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[18px] 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-[18px] 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-[18px] 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-[18px] 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-[20px] 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-[20px] 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-[20px] 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-[18px] 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-[18px] 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>
@@ -236,6 +529,7 @@ export function ShipmentDetailPage() {
</div> </div>
)} )}
</section> </section>
<FileAttachmentsPanel <FileAttachmentsPanel
ownerType="SHIPMENT" ownerType="SHIPMENT"
ownerId={shipment.id} ownerId={shipment.id}
@@ -244,6 +538,7 @@ 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 <ConfirmActionDialog
open={pendingConfirmation != null} open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm shipment action"} title={pendingConfirmation?.title ?? "Confirm shipment action"}
@@ -271,4 +566,3 @@ export function ShipmentDetailPage() {
</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-[20px] 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-[20px] 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">

View File

@@ -38,12 +38,11 @@ export function ShipmentListPage() {
}, [searchTerm, statusFilter, token]); }, [searchTerm, statusFilter, token]);
return ( return (
<section className="rounded-[20px] 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-[18px] 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-[18px] 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>

File diff suppressed because it is too large Load Diff

1
fabdash Submodule

Submodule fabdash added at fe4d8b120c

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

@@ -26,6 +26,10 @@ model User {
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")
@@ -162,6 +166,7 @@ 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[]
@@ -222,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")
@@ -293,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")
@@ -395,6 +402,7 @@ model Vendor {
contactEntries CrmContactEntry[] contactEntries CrmContactEntry[]
contacts CrmContact[] contacts CrmContact[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
capexEntries CapexEntry[]
preferredSupplyItems InventoryItem[] preferredSupplyItems InventoryItem[]
} }
@@ -490,6 +498,7 @@ model SalesOrder {
revisions SalesOrderRevision[] revisions SalesOrderRevision[]
workOrders WorkOrder[] workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[] purchaseOrderLines PurchaseOrderLine[]
customerPayments FinanceCustomerPayment[]
} }
model SalesOrderLine { model SalesOrderLine {
@@ -507,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])
} }
@@ -558,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
@@ -583,6 +618,7 @@ 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[] milestones ProjectMilestone[]
@@index([customerId, createdAt]) @@index([customerId, createdAt])
@@ -617,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?
@@ -633,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])
@@ -648,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
@@ -677,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)
@@ -685,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 {
@@ -729,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)
@@ -741,9 +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[] revisions PurchaseOrderRevision[]
capexEntries CapexEntry[]
@@index([projectId, issueDate])
} }
model PurchaseOrderLine { model PurchaseOrderLine {

View File

@@ -18,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";
@@ -97,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

@@ -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",
@@ -122,4 +124,11 @@ export async function bootstrapAppData() {
}, },
}); });
} }
const existingFinanceProfile = await prisma.financeProfile.findFirst();
if (!existingFinanceProfile) {
await prisma.financeProfile.create({
data: {},
});
}
} }

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

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

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

@@ -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({
@@ -51,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;
} }
@@ -147,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);
});

View File

@@ -4,12 +4,15 @@ import type {
ProjectCockpitReceiptDto, ProjectCockpitReceiptDto,
ProjectCockpitRiskLevel, ProjectCockpitRiskLevel,
ProjectCockpitVendorDto, ProjectCockpitVendorDto,
ProjectTimelineEntryDto,
ProjectCustomerOptionDto, ProjectCustomerOptionDto,
ProjectDetailDto, ProjectDetailDto,
ProjectDocumentOptionDto, ProjectDocumentOptionDto,
ProjectInput, ProjectInput,
ProjectMilestoneDto, ProjectMilestoneDto,
ProjectMilestoneInput, ProjectMilestoneInput,
ProjectMilestoneStatus,
ProjectMilestoneStatusUpdateInput,
ProjectOwnerOptionDto, ProjectOwnerOptionDto,
ProjectPriority, ProjectPriority,
ProjectRollupDto, ProjectRollupDto,
@@ -156,6 +159,19 @@ type ProjectCostWorkOrderRecord = {
}>; }>;
}; };
type ProjectAuditEventRecord = {
id: string;
entityType: string;
entityId: string | null;
action: string;
summary: string;
createdAt: Date;
actor: {
firstName: string;
lastName: string;
} | null;
};
function roundMoney(value: number) { function roundMoney(value: number) {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
@@ -223,6 +239,160 @@ function deriveProjectRiskLevel(score: number): ProjectCockpitRiskLevel {
return "HIGH"; return "HIGH";
} }
function getActorName(actor: { firstName: string; lastName: string } | null) {
return actor ? `${actor.firstName} ${actor.lastName}`.trim() : null;
}
async function buildProjectTimeline(record: ProjectRecord): Promise<ProjectTimelineEntryDto[]> {
const relatedEntityFilters = [
{ entityType: "project", entityId: record.id },
...(record.salesQuote ? [{ entityType: "sales-quote", entityId: record.salesQuote.id }] : []),
...(record.salesOrder ? [{ entityType: "sales-order", entityId: record.salesOrder.id }] : []),
...(record.shipment ? [{ entityType: "shipment", entityId: record.shipment.id }] : []),
...record.workOrders.map((workOrder) => ({ entityType: "work-order", entityId: workOrder.id })),
];
const [auditEvents, purchaseOrders] = await Promise.all([
prisma.auditEvent.findMany({
where: {
OR: relatedEntityFilters,
},
include: {
actor: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
take: 30,
}),
record.salesOrder
? prisma.purchaseOrder.findMany({
where: {
lines: {
some: {
salesOrderId: record.salesOrder.id,
},
},
},
select: {
id: true,
documentNumber: true,
createdAt: true,
receipts: {
select: {
id: true,
receiptNumber: true,
receivedAt: true,
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ receivedAt: "desc" }],
},
},
})
: Promise.resolve([]),
]);
const timeline: ProjectTimelineEntryDto[] = [];
for (const milestone of record.milestones) {
timeline.push({
id: `milestone-${milestone.id}-created`,
sourceType: "MILESTONE",
title: `Milestone planned: ${milestone.title}`,
detail: milestone.dueDate ? `Due ${milestone.dueDate.toLocaleDateString()}` : "No due date assigned",
createdAt: milestone.dueDate?.toISOString() ?? new Date(0).toISOString(),
actorName: null,
href: null,
});
if (milestone.completedAt) {
timeline.push({
id: `milestone-${milestone.id}-completed`,
sourceType: "MILESTONE",
title: `Milestone completed: ${milestone.title}`,
detail: "Checkpoint marked complete.",
createdAt: milestone.completedAt.toISOString(),
actorName: null,
href: null,
});
}
}
for (const auditEvent of auditEvents as ProjectAuditEventRecord[]) {
let sourceType: ProjectTimelineEntryDto["sourceType"] = "PROJECT";
let href: string | null = null;
if (auditEvent.entityType === "sales-quote" || auditEvent.entityType === "sales-order") {
sourceType = "SALES";
href = auditEvent.entityType === "sales-quote" ? `/sales/quotes/${auditEvent.entityId}` : `/sales/orders/${auditEvent.entityId}`;
} else if (auditEvent.entityType === "shipment") {
sourceType = "SHIPPING";
href = `/shipping/shipments/${auditEvent.entityId}`;
} else if (auditEvent.entityType === "work-order") {
sourceType = "MANUFACTURING";
href = `/manufacturing/work-orders/${auditEvent.entityId}`;
}
timeline.push({
id: `audit-${auditEvent.id}`,
sourceType,
title: auditEvent.summary,
detail: `${auditEvent.entityType} · ${auditEvent.action}`.replaceAll("-", " "),
createdAt: auditEvent.createdAt.toISOString(),
actorName: getActorName(auditEvent.actor),
href,
});
}
for (const purchaseOrder of purchaseOrders as Array<{
id: string;
documentNumber: string;
createdAt: Date;
receipts: Array<{
id: string;
receiptNumber: string;
receivedAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
}>) {
timeline.push({
id: `purchase-order-${purchaseOrder.id}`,
sourceType: "PURCHASING",
title: `Linked purchase order ${purchaseOrder.documentNumber}`,
detail: "Project demand is now covered by purchasing.",
createdAt: purchaseOrder.createdAt.toISOString(),
actorName: null,
href: `/purchasing/orders/${purchaseOrder.id}`,
});
for (const receipt of purchaseOrder.receipts) {
timeline.push({
id: `receipt-${receipt.id}`,
sourceType: "PURCHASING",
title: `Receipt posted: ${receipt.receiptNumber}`,
detail: `Received against ${purchaseOrder.documentNumber}.`,
createdAt: receipt.receivedAt.toISOString(),
actorName: getActorName(receipt.createdBy),
href: `/purchasing/orders/${purchaseOrder.id}`,
});
}
}
return timeline
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())
.slice(0, 20);
}
async function buildProjectCockpit(record: ProjectRecord, rollups: ProjectRollupDto): Promise<ProjectCockpitDto> { async function buildProjectCockpit(record: ProjectRecord, rollups: ProjectRollupDto): Promise<ProjectCockpitDto> {
const blockedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "BLOCKED").length; const blockedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "BLOCKED").length;
@@ -594,7 +764,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
}; };
} }
function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): ProjectDetailDto { function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto, timeline: ProjectTimelineEntryDto[]): ProjectDetailDto {
return { return {
...mapProjectSummary(record), ...mapProjectSummary(record),
notes: record.notes, notes: record.notes,
@@ -609,6 +779,7 @@ function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): Pr
customerPhone: record.customer.phone, customerPhone: record.customer.phone,
milestones: record.milestones.map(mapProjectMilestone), milestones: record.milestones.map(mapProjectMilestone),
cockpit, cockpit,
timeline,
}; };
} }
@@ -980,8 +1151,11 @@ export async function getProjectById(projectId: string) {
} }
const mappedProject = project as ProjectRecord; const mappedProject = project as ProjectRecord;
const cockpit = await buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject)); const [cockpit, timeline] = await Promise.all([
return mapProjectDetail(mappedProject, cockpit); buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject)),
buildProjectTimeline(mappedProject),
]);
return mapProjectDetail(mappedProject, cockpit, timeline);
} }
export async function createProject(payload: ProjectInput, actorId?: string | null) { export async function createProject(payload: ProjectInput, actorId?: string | null) {
@@ -1094,3 +1268,60 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
} }
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." }; return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
} }
export async function updateProjectMilestoneStatus(
projectId: string,
milestoneId: string,
payload: ProjectMilestoneStatusUpdateInput,
actorId?: string | null
) {
const existing = await prisma.projectMilestone.findUnique({
where: { id: milestoneId },
select: {
id: true,
projectId: true,
title: true,
status: true,
completedAt: true,
project: {
select: {
id: true,
projectNumber: true,
},
},
},
});
if (!existing || existing.projectId !== projectId) {
return { ok: false as const, reason: "Project milestone was not found." };
}
const nextStatus = payload.status as ProjectMilestoneStatus;
await prisma.projectMilestone.update({
where: { id: milestoneId },
data: {
status: nextStatus,
completedAt: nextStatus === "COMPLETE" ? existing.completedAt ?? new Date() : null,
},
});
const project = await getProjectById(projectId);
if (project) {
await logAuditEvent({
actorId,
entityType: "project",
entityId: projectId,
action: "milestone.status.updated",
summary: `Updated milestone ${existing.title} on ${existing.project.projectNumber} to ${nextStatus}.`,
metadata: {
projectNumber: existing.project.projectNumber,
milestoneId,
milestoneTitle: existing.title,
previousStatus: existing.status,
status: nextStatus,
},
});
}
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
}

View File

@@ -29,6 +29,7 @@ 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),

View File

@@ -126,6 +126,11 @@ type PurchaseOrderRevisionRecord = {
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;
@@ -145,6 +150,17 @@ type PurchaseOrderRecord = {
revisions: PurchaseOrderRevisionRecord[]; 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) {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
@@ -322,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>();
@@ -370,6 +443,9 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
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,
@@ -487,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: {
@@ -715,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,
@@ -762,6 +848,11 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
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 },
@@ -777,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,
@@ -824,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 },
@@ -837,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,

View File

@@ -97,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[];
}; };
@@ -279,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,
@@ -383,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: {
@@ -799,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

@@ -10,6 +10,8 @@ export const permissions = {
manufacturingWrite: "manufacturing.write", manufacturingWrite: "manufacturing.write",
filesRead: "files.read", filesRead: "files.read",
filesWrite: "files.write", filesWrite: "files.write",
financeRead: "finance.read",
financeWrite: "finance.write",
ganttRead: "gantt.read", ganttRead: "gantt.read",
salesRead: "sales.read", salesRead: "sales.read",
salesWrite: "sales.write", salesWrite: "sales.write",

131
shared/src/finance/types.ts Normal file
View File

@@ -0,0 +1,131 @@
export const financePaymentTypes = ["DEPOSIT", "PROGRESS", "FINAL", "ADJUSTMENT"] as const;
export const financePaymentMethods = ["ACH", "WIRE", "CHECK", "CARD", "CASH", "OTHER"] as const;
export const capexCategories = ["EQUIPMENT", "TOOLING", "CONSUMABLE"] as const;
export const capexStatuses = ["PLANNED", "APPROVED", "ORDERED", "IN_SERVICE", "CLOSED", "CANCELLED"] as const;
export type FinancePaymentType = (typeof financePaymentTypes)[number];
export type FinancePaymentMethod = (typeof financePaymentMethods)[number];
export type CapexCategory = (typeof capexCategories)[number];
export type CapexStatus = (typeof capexStatuses)[number];
export interface FinanceProfileDto {
id: string;
currencyCode: string;
standardLaborRatePerHour: number;
overheadRatePerHour: number;
createdAt: string;
updatedAt: string;
}
export interface FinanceProfileInput {
currencyCode: string;
standardLaborRatePerHour: number;
overheadRatePerHour: number;
}
export interface FinanceCustomerPaymentDto {
id: string;
salesOrderId: string;
salesOrderNumber: string;
customerId: string;
customerName: string;
paymentType: FinancePaymentType;
paymentMethod: FinancePaymentMethod;
paymentDate: string;
amount: number;
reference: string;
notes: string;
createdAt: string;
createdByName: string;
}
export interface FinanceCustomerPaymentInput {
salesOrderId: string;
paymentType: FinancePaymentType;
paymentMethod: FinancePaymentMethod;
paymentDate: string;
amount: number;
reference: string;
notes: string;
}
export interface FinanceCapexDto {
id: string;
title: string;
category: CapexCategory;
status: CapexStatus;
vendorId: string | null;
vendorName: string | null;
purchaseOrderId: string | null;
purchaseOrderNumber: string | null;
plannedAmount: number;
actualAmount: number;
requestDate: string;
targetInServiceDate: string | null;
purchasedAt: string | null;
notes: string;
createdAt: string;
updatedAt: string;
}
export interface FinanceCapexInput {
title: string;
category: CapexCategory;
status: CapexStatus;
vendorId: string | null;
purchaseOrderId: string | null;
plannedAmount: number;
actualAmount: number;
requestDate: string;
targetInServiceDate: string | null;
purchasedAt: string | null;
notes: string;
}
export interface FinanceSalesOrderLedgerDto {
salesOrderId: string;
salesOrderNumber: string;
customerId: string;
customerName: string;
status: string;
issueDate: string;
revenueTotal: number;
paymentsReceived: number;
accountsReceivableOpen: number;
linkedPurchaseCommitted: number;
linkedPurchaseReceivedValue: number;
manufacturingMaterialCost: number;
manufacturingLaborCost: number;
manufacturingOverheadCost: number;
manufacturingTotalCost: number;
totalRecognizedSpend: number;
grossMarginEstimate: number;
grossMarginPercent: number;
paymentCoveragePercent: number;
linkedPurchaseOrderCount: number;
linkedWorkOrderCount: number;
}
export interface FinanceSummaryDto {
bookedRevenue: number;
paymentsReceived: number;
accountsReceivableOpen: number;
linkedPurchaseCommitted: number;
linkedPurchaseReceivedValue: number;
manufacturingMaterialCost: number;
manufacturingLaborCost: number;
manufacturingOverheadCost: number;
manufacturingTotalCost: number;
capexPlanned: number;
capexActual: number;
grossMarginEstimate: number;
}
export interface FinanceDashboardDto {
generatedAt: string;
profile: FinanceProfileDto;
summary: FinanceSummaryDto;
salesOrderLedgers: FinanceSalesOrderLedgerDto[];
payments: FinanceCustomerPaymentDto[];
capex: FinanceCapexDto[];
}

View File

@@ -1,3 +1,15 @@
export const planningReadinessStates = ["READY", "SHORTAGE", "PENDING_SUPPLY", "UNSCHEDULED", "BLOCKED"] as const;
export type PlanningReadinessState = (typeof planningReadinessStates)[number];
export interface PlanningTaskActionDto {
kind: "OPEN_RECORD" | "RELEASE_WORK_ORDER" | "CREATE_WORK_ORDER" | "CREATE_PURCHASE_ORDER";
label: string;
href?: string | null;
workOrderId?: string | null;
itemId?: string | null;
}
export interface GanttTaskDto { export interface GanttTaskDto {
id: string; id: string;
text: string; text: string;
@@ -9,6 +21,29 @@ export interface GanttTaskDto {
status?: string; status?: string;
ownerLabel?: string | null; ownerLabel?: string | null;
detailHref?: string | null; detailHref?: string | null;
entityId?: string | null;
projectId?: string | null;
workOrderId?: string | null;
salesOrderId?: string | null;
salesOrderLineId?: string | null;
itemId?: string | null;
itemSku?: string | null;
stationId?: string | null;
stationCode?: string | null;
stationName?: string | null;
readinessState?: PlanningReadinessState;
readinessScore?: number;
shortageItemCount?: number;
totalShortageQuantity?: number;
linkedSupplyQuantity?: number;
openSupplyQuantity?: number;
releaseReady?: boolean;
overdue?: boolean;
blockedReason?: string | null;
loadMinutes?: number;
capacityMinutes?: number | null;
utilizationPercent?: number | null;
actions?: PlanningTaskActionDto[];
} }
export interface GanttLinkDto { export interface GanttLinkDto {
@@ -25,6 +60,10 @@ export interface PlanningSummaryDto {
activeWorkOrders: number; activeWorkOrders: number;
overdueWorkOrders: number; overdueWorkOrders: number;
unscheduledWorkOrders: number; unscheduledWorkOrders: number;
releaseReadyWorkOrders: number;
blockedWorkOrders: number;
stationCount: number;
overloadedStations: number;
horizonStart: string; horizonStart: string;
horizonEnd: string; horizonEnd: string;
} }
@@ -39,9 +78,40 @@ export interface PlanningExceptionDto {
detailHref: string; detailHref: string;
} }
export interface PlanningStationLoadDto {
stationId: string;
stationCode: string;
stationName: string;
operationCount: number;
workOrderCount: number;
totalPlannedMinutes: number;
totalActualMinutes: number;
capacityMinutes: number;
utilizationPercent: number;
actualUtilizationPercent: number;
overloaded: boolean;
blockedCount: number;
readyCount: number;
lateCount: number;
}
export interface PlanningStationDayLoadDto {
stationId: string;
dateKey: string;
plannedMinutes: number;
actualMinutes: number;
capacityMinutes: number;
utilizationPercent: number;
actualUtilizationPercent: number;
operationCount: number;
overloaded: boolean;
}
export interface PlanningTimelineDto { export interface PlanningTimelineDto {
tasks: GanttTaskDto[]; tasks: GanttTaskDto[];
links: GanttLinkDto[]; links: GanttLinkDto[];
summary: PlanningSummaryDto; summary: PlanningSummaryDto;
exceptions: PlanningExceptionDto[]; exceptions: PlanningExceptionDto[];
stationLoads: PlanningStationLoadDto[];
stationDayLoads: PlanningStationDayLoadDto[];
} }

View File

@@ -5,6 +5,7 @@ export * from "./common/api.js";
export * from "./company/types.js"; export * from "./company/types.js";
export * from "./crm/types.js"; export * from "./crm/types.js";
export * from "./files/types.js"; export * from "./files/types.js";
export * from "./finance/types.js";
export * from "./gantt/types.js"; export * from "./gantt/types.js";
export * from "./inventory/types.js"; export * from "./inventory/types.js";
export * from "./manufacturing/types.js"; export * from "./manufacturing/types.js";

View File

@@ -1,6 +1,8 @@
export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD", "COMPLETE", "CANCELLED"] as const; export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD", "COMPLETE", "CANCELLED"] as const;
export const workOrderOperationStatuses = ["PENDING", "IN_PROGRESS", "PAUSED", "COMPLETE"] as const;
export type WorkOrderStatus = (typeof workOrderStatuses)[number]; export type WorkOrderStatus = (typeof workOrderStatuses)[number];
export type WorkOrderOperationStatus = (typeof workOrderOperationStatuses)[number];
export interface ManufacturingStationDto { export interface ManufacturingStationDto {
id: string; id: string;
@@ -8,6 +10,9 @@ export interface ManufacturingStationDto {
name: string; name: string;
description: string; description: string;
queueDays: number; queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: number[];
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -18,6 +23,9 @@ export interface ManufacturingStationInput {
name: string; name: string;
description: string; description: string;
queueDays: number; queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: number[];
isActive: boolean; isActive: boolean;
} }
@@ -29,6 +37,12 @@ export interface ManufacturingProjectOptionDto {
status: string; status: string;
} }
export interface ManufacturingUserOptionDto {
id: string;
name: string;
email: string;
}
export interface ManufacturingItemOptionDto { export interface ManufacturingItemOptionDto {
id: string; id: string;
sku: string; sku: string;
@@ -71,6 +85,9 @@ export interface WorkOrderOperationDto {
stationId: string; stationId: string;
stationCode: string; stationCode: string;
stationName: string; stationName: string;
stationDailyCapacityMinutes: number;
stationParallelCapacity: number;
stationWorkingDays: number[];
sequence: number; sequence: number;
setupMinutes: number; setupMinutes: number;
runMinutesPerUnit: number; runMinutesPerUnit: number;
@@ -79,6 +96,23 @@ export interface WorkOrderOperationDto {
plannedStart: string; plannedStart: string;
plannedEnd: string; plannedEnd: string;
notes: string; notes: string;
status: WorkOrderOperationStatus;
actualStart: string | null;
actualEnd: string | null;
actualMinutes: number;
laborEntryCount: number;
assignedOperatorId: string | null;
assignedOperatorName: string | null;
activeTimerStartedAt: string | null;
laborEntries: WorkOrderOperationLaborEntryDto[];
}
export interface WorkOrderOperationLaborEntryDto {
id: string;
minutes: number;
notes: string;
createdAt: string;
createdByName: string;
} }
export interface WorkOrderMaterialRequirementDto { export interface WorkOrderMaterialRequirementDto {
@@ -123,11 +157,13 @@ export interface WorkOrderCompletionDto {
export interface WorkOrderDetailDto extends WorkOrderSummaryDto { export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
notes: string; notes: string;
holdReason: string | null;
createdAt: string; createdAt: string;
itemType: string; itemType: string;
itemUnitOfMeasure: string; itemUnitOfMeasure: string;
projectCustomerName: string | null; projectCustomerName: string | null;
dueQuantity: number; dueQuantity: number;
totalActualMinutes: number;
operations: WorkOrderOperationDto[]; operations: WorkOrderOperationDto[];
materialRequirements: WorkOrderMaterialRequirementDto[]; materialRequirements: WorkOrderMaterialRequirementDto[];
materialIssues: WorkOrderMaterialIssueDto[]; materialIssues: WorkOrderMaterialIssueDto[];
@@ -159,3 +195,32 @@ export interface WorkOrderCompletionInput {
quantity: number; quantity: number;
notes: string; notes: string;
} }
export interface WorkOrderOperationScheduleInput {
plannedStart: string;
stationId?: string | null;
}
export interface WorkOrderOperationExecutionInput {
action: "START" | "PAUSE" | "RESUME" | "COMPLETE";
notes: string;
}
export interface WorkOrderOperationLaborEntryInput {
minutes: number;
notes: string;
}
export interface WorkOrderOperationAssignmentInput {
assignedOperatorId: string | null;
}
export interface WorkOrderOperationTimerInput {
action: "START" | "STOP";
notes: string;
}
export interface WorkOrderStatusUpdateInput {
status: WorkOrderStatus;
reason?: string | null;
}

View File

@@ -170,6 +170,16 @@ export interface ProjectCockpitDto {
risk: ProjectCockpitRiskDto; risk: ProjectCockpitRiskDto;
} }
export interface ProjectTimelineEntryDto {
id: string;
sourceType: "PROJECT" | "MILESTONE" | "SALES" | "PURCHASING" | "MANUFACTURING" | "SHIPPING";
title: string;
detail: string;
createdAt: string;
actorName: string | null;
href: string | null;
}
export interface ProjectMilestoneInput { export interface ProjectMilestoneInput {
id?: string | null; id?: string | null;
title: string; title: string;
@@ -179,6 +189,10 @@ export interface ProjectMilestoneInput {
sortOrder: number; sortOrder: number;
} }
export interface ProjectMilestoneStatusUpdateInput {
status: ProjectMilestoneStatus;
}
export interface ProjectDetailDto extends ProjectSummaryDto { export interface ProjectDetailDto extends ProjectSummaryDto {
notes: string; notes: string;
createdAt: string; createdAt: string;
@@ -192,6 +206,7 @@ export interface ProjectDetailDto extends ProjectSummaryDto {
customerPhone: string; customerPhone: string;
milestones: ProjectMilestoneDto[]; milestones: ProjectMilestoneDto[];
cockpit: ProjectCockpitDto; cockpit: ProjectCockpitDto;
timeline: ProjectTimelineEntryDto[];
} }
export interface ProjectInput { export interface ProjectInput {

View File

@@ -46,6 +46,9 @@ export interface PurchaseOrderSummaryDto {
documentNumber: string; documentNumber: string;
vendorId: string; vendorId: string;
vendorName: string; vendorName: string;
projectId: string | null;
projectNumber: string | null;
projectName: string | null;
status: PurchaseOrderStatus; status: PurchaseOrderStatus;
subtotal: number; subtotal: number;
taxPercent: number; taxPercent: number;
@@ -70,6 +73,7 @@ export interface PurchaseOrderDetailDto extends PurchaseOrderSummaryDto {
export interface PurchaseOrderInput { export interface PurchaseOrderInput {
vendorId: string; vendorId: string;
projectId?: string | null;
status: PurchaseOrderStatus; status: PurchaseOrderStatus;
issueDate: string; issueDate: string;
taxPercent: number; taxPercent: number;

View File

@@ -60,6 +60,9 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
notes: string; notes: string;
expiresAt: string | null; expiresAt: string | null;
createdAt: string; createdAt: string;
linkedProjectId: string | null;
linkedProjectNumber: string | null;
linkedProjectName: string | null;
lines: SalesLineDto[]; lines: SalesLineDto[];
revisions: SalesDocumentRevisionDto[]; revisions: SalesDocumentRevisionDto[];
} }

View File

@@ -24,10 +24,42 @@ export interface ShipmentSummaryDto {
updatedAt: string; updatedAt: string;
} }
export interface ShipmentPickDto {
id: string;
salesOrderLineId: string;
itemId: string;
itemSku: string;
itemName: string;
quantity: number;
warehouseId: string;
warehouseCode: string;
warehouseName: string;
locationId: string;
locationCode: string;
locationName: string;
notes: string;
createdAt: string;
createdByName: string;
}
export interface ShipmentLineDto {
salesOrderLineId: string;
itemId: string;
itemSku: string;
itemName: string;
description: string;
orderedQuantity: number;
pickedQuantity: number;
remainingQuantity: number;
unitOfMeasure: string;
}
export interface ShipmentDetailDto extends ShipmentSummaryDto { export interface ShipmentDetailDto extends ShipmentSummaryDto {
serviceLevel: string; serviceLevel: string;
notes: string; notes: string;
createdAt: string; createdAt: string;
lines: ShipmentLineDto[];
picks: ShipmentPickDto[];
} }
export interface ShipmentInput { export interface ShipmentInput {
@@ -40,3 +72,11 @@ export interface ShipmentInput {
packageCount: number; packageCount: number;
notes: string; notes: string;
} }
export interface ShipmentPickInput {
salesOrderLineId: string;
warehouseId: string;
locationId: string;
quantity: number;
notes: string;
}

96
test-puppeteer.js Normal file
View File

@@ -0,0 +1,96 @@
import puppeteer from 'puppeteer';
import fs from 'fs';
const html = `
<html>
<head>
<style>
@page { size: 4in 6in; margin: 0; }
*, *::before, *::after { box-sizing: border-box; }
html, body { width: 4in; height: 6in; margin: 0; padding: 0.5in; overflow: hidden; }
body { font-family: Arial, sans-serif; color: #111827; font-size: 11px; }
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; height: 100%; overflow: hidden; }
.row { display: flex; justify-content: space-between; gap: 12px; }
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
.brand { border-bottom: 2px solid #ed8936; padding-bottom: 10px; }
.brand h1 { margin: 0; font-size: 18px; color: #ed8936; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
.strong { font-weight: 700; }
.big { font-size: 16px; font-weight: 700; }
</style>
</head>
<body>
<div class="label">
<div class="brand">
<div class="row">
<div>
<div class="muted">From</div>
<h1>Message Point Media</h1>
</div>
<div style="text-align:right;">
<div class="muted">Shipment</div>
<div class="big">SHP-00003</div>
</div>
</div>
</div>
<div class="block">
<div class="muted">Ship To</div>
<div class="stack" style="margin-top:8px;">
<div class="strong">Northwind Fabrication</div>
<div>42 Assembly Ave</div>
<div>Milwaukee, WI 53202</div>
<div>USA</div>
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Service</div>
<div class="big" style="margin-top:6px;">GROUND</div>
</div>
<div class="block" style="width:90px;">
<div class="muted">Pkgs</div>
<div class="big" style="margin-top:6px;">1</div>
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Sales Order</div>
<div class="strong" style="margin-top:6px;">SO-00002</div>
</div>
<div class="block" style="width:110px;">
<div class="muted">Ship Date</div>
<div class="strong" style="margin-top:6px;">N/A</div>
</div>
</div>
<div class="block">
<div class="muted">Reference</div>
<div style="margin-top:6px;">FG-CTRL-BASE · Control Base Assembly</div>
</div>
<div class="barcode">
*SHP-00003*
</div>
<div style="text-align:center; font-size:10px; color:#4b5563;">Carrier pending · Tracking pending</div>
</div>
</body>
</html>
`;
async function run() {
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
printBackground: true,
preferCSSPageSize: true,
});
fs.writeFileSync('/tmp/test-label.pdf', pdf);
console.log("PDF generated at /tmp/test-label.pdf");
} finally {
await browser.close();
}
}
run();

71
usage_guide.md Normal file
View File

@@ -0,0 +1,71 @@
# CODEXIUM: End-to-End Workflow Guide
This guide walks through the core operational workflow in CODEXIUM, starting from capturing a customer request in a Quote, all the way to shipping the final product.
## 1. Create a Quote
The process begins when a customer requests pricing.
1. Navigate to the **Quotes** module.
2. Click to create a **New Quote**.
3. Select the Customer using the searchable lookup.
4. Add **Quote Lines** by searching for the requested Inventory SKUs. The default price from the item master will automatically populate.
5. Add any necessary discounts, freight, and notes.
6. The Quote can go through an approval process. Once the customer accepts the terms, proceed to the next step.
## 2. Convert to Sales Order
Once the Quote is accepted, it becomes firm demand.
1. Open the approved **Quote**.
2. Use the action menu to **Convert to Sales Order**.
3. This creates a new Sales Order record, carrying over all lines, pricing, and customer information.
4. The Sales Order is now the authoritative commercial record for this demand.
## 3. Create a Project
For complex or long-running deliveries, create a Project to track the execution.
1. Navigate to the **Projects** module.
2. Click **New Project** and select the same Customer.
3. Define the project scope, priority, due dates, and owner.
4. Set up high-level **Milestones** to track progress on deliverables.
## 4. Assign Quote and Sales Order to Project
Link the commercial documents to the execution tracker.
1. Open the newly created **Project**.
2. In the project details, link the original **Quote** and the active **Sales Order**.
3. *Note: You can also link the Project from within the Quote and Sales Order detail pages. This reverse-link ensures that quote conversion automatically carries the project context into the Sales Order.*
4. The Project dashboard now provides visibility into the commercial value and linked deliverables.
## 5. Determine Manufacturing & Supply Requirements (Demand Planning)
With the Sales Order firm and linked to a Project, determine what needs to be made or bought.
1. Navigate to the **Workbench** module, where the **Demand Planning** view is available for the Sales Order.
2. The system runs a **Multi-level BOM explosion** against the Sales Order lines, netting against current stock and open supply (existing POs/MOs).
3. The system will generate **Build/Buy recommendations** for any shortages.
## 6. Issue Purchase Orders (POs)
Fulfill the "Buy" recommendations.
1. From the Demand Planning recommendations, use the planner-assisted conversion to draft **Purchase Orders** for the required buyout items and raw materials.
2. The POs will automatically peg back to the source Sales Order and carry the Project context.
3. Preferred vendors from the inventory item master are selected by default.
4. Send the PO PDFs to vendors and manage receiving in the **Purchase Orders** and **Warehouse** modules.
## 7. Issue Manufacturing Orders (MOs/Work Orders)
Fulfill the "Build" recommendations.
1. Again, from the Demand Planning recommendations, convert the "Build" shortages into **Work Orders**.
2. The Work Orders define the execution plan (Operations/Stations) based on the item's routing templates in the **Manufacturing** module.
3. Provide the Work Order to the shop floor.
4. As operators log labor and issue materials against the Work Order, the costs roll up, and final completion posts the finished goods to inventory, making them available for shipment.
## 8. Assign Shipping
Once production is complete, deliver the goods.
1. Navigate to the **Shipments** module and create a **New Shipment**.
2. Link the Shipment directly to the **Sales Order**.
3. The system shows the ordered vs. picked vs. remaining quantities.
4. Execute **Shipment Picking**, which pulls stock from specific warehouse locations and posts the inventory issue transactions.
5. Update logistics details: Carrier, Service Level, Tracking Number, and Package Count.
6. Generate branded logistics PDFs directly from the Shipment: **Packing Slips, Shipping Labels, and Bills of Lading**.
7. The Shipment can also be seen from the linked Project, closing the loop on the delivery lifecycle.