Compare commits
70 Commits
1fcb0c5480
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e65ed892f1 | |||
|
|
ce2d52db53 | ||
|
|
39fd876d51 | ||
|
|
0c3b2cf6fe | ||
|
|
6423dfb91b | ||
|
|
26b188de87 | ||
|
|
0b43b4ebf5 | ||
|
|
3c312733ca | ||
|
|
9d54dc2ecd | ||
|
|
b762c70238 | ||
|
|
9562c1cc9c | ||
| 3eba7c5fa6 | |||
| 4949b6033f | |||
| cf54e4ba58 | |||
| 061057339b | |||
| 7b65fe06cf | |||
| d22e715f00 | |||
| 5fdd366bc3 | |||
| afad00bf46 | |||
| 28ea1ee6b9 | |||
| 00a4da346f | |||
| 52bc98c16e | |||
| 17b73a4597 | |||
| dc07bfc8e0 | |||
| 1e408d5316 | |||
| 69dfec98ad | |||
| f12744f05d | |||
| c18de77640 | |||
| f85563ce99 | |||
| 02e14319ac | |||
| e00639bb8b | |||
| c49ed4bf4a | |||
| 6eaf084fcd | |||
| abc795b4a7 | |||
| 14708d7013 | |||
| 66d8814d89 | |||
| b02b764b2f | |||
| c06cb66893 | |||
| cdbd54b8cc | |||
| f772ccacc7 | |||
| 7993f16a76 | |||
| c1f6386e7d | |||
| c3f0adc676 | |||
| 279c46fbde | |||
| 0d7282664e | |||
| c6931d5c5d | |||
| a1b5d7aa84 | |||
| f60d534f64 | |||
| daced2b7c9 | |||
| 89282896e8 | |||
| f0351cbed5 | |||
| b0ea997b8b | |||
| 26ee928869 | |||
| 2718e8b4b1 | |||
| f2b820746a | |||
| 8029b308e9 | |||
| ac0c6e4365 | |||
| a43374fe77 | |||
| f3e421e9e3 | |||
| e88d949a59 | |||
| dcac4f135d | |||
| 275c73b584 | |||
| df041254da | |||
| 59754c7657 | |||
| 15116807ce | |||
| f858fe4785 | |||
| e7cfff3eca | |||
| 28b23bc355 | |||
| 3197e68749 | |||
| 857d34397e |
Submodule .claude/worktrees/inspiring-leavitt deleted from 2cf6bf858d
25
AGENTS.md
25
AGENTS.md
@@ -6,7 +6,7 @@ This file defines project-specific guidance for future contributors and coding a
|
||||
|
||||
## Project overview
|
||||
|
||||
MRP Codex is a modular Manufacturing Resource Planning platform intended to be a lighter, sleeker alternative to Odoo. The current repository contains the foundation release:
|
||||
CODEXIUM is a modular Manufacturing Resource Planning platform intended to be a lighter, sleeker alternative to Odoo. The current repository contains the foundation release:
|
||||
|
||||
- React + Vite + Tailwind frontend
|
||||
- Express + TypeScript backend
|
||||
@@ -17,12 +17,27 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
|
||||
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
|
||||
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
|
||||
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
||||
- sales quotes, sales orders, approvals, revision history, and purchase orders
|
||||
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
|
||||
- inventory thumbnail image staging on create/edit and dedicated thumbnail display on item detail
|
||||
- sales quotes, sales orders, approvals, revision history/comparison, and purchase orders
|
||||
- purchase-order revision history and revision comparison across document and receipt changes
|
||||
- purchase-order supporting documents and vendor-side purchasing visibility
|
||||
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
|
||||
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
|
||||
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments
|
||||
- planning gantt timelines backed by live project and manufacturing schedule data
|
||||
- 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
|
||||
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
|
||||
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
|
||||
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
|
||||
- admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, and review filtering
|
||||
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
|
||||
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
|
||||
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
||||
- backup verification checklist and restore-drill runbook in the admin diagnostics workflow
|
||||
- support-log viewing, filtering, retention cleanup, and support debugging helpers in the admin diagnostics workflow
|
||||
- startup brand-theme hydration so Company Settings colors persist across refresh
|
||||
- Puppeteer PDF foundation
|
||||
- single-container Docker deployment
|
||||
|
||||
@@ -35,6 +50,7 @@ Read these before major work:
|
||||
- [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md)
|
||||
- [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md)
|
||||
- [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md)
|
||||
- [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md)
|
||||
- [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md)
|
||||
|
||||
If implementation changes invalidate those docs, update them in the same change set. Keep `CHANGELOG.md` current for shipped features, behavior changes, and notable operational updates.
|
||||
@@ -119,8 +135,8 @@ If implementation changes invalidate those docs, update them in the same change
|
||||
|
||||
Near-term priorities are:
|
||||
|
||||
1. Broader audit-trail coverage and operational diagnostics
|
||||
2. Code-splitting and bundle-size reduction
|
||||
1. Project milestones and project-side rollup visibility
|
||||
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||
|
||||
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.
|
||||
|
||||
@@ -149,3 +165,4 @@ If you cannot run one of those checks, say so explicitly.
|
||||
- Prisma runtime on Debian bookworm requires `debian-openssl-3.0.x`
|
||||
- `shared` package exports must use Node ESM-compatible `.js` specifiers
|
||||
- Local Docker validation may fail if the Docker daemon is unavailable; distinguish daemon issues from image issues
|
||||
|
||||
|
||||
107
CHANGELOG.md
107
CHANGELOG.md
@@ -1,17 +1,93 @@
|
||||
# Changelog
|
||||
|
||||
This file is the running release and change log for MRP Codex. Keep it updated whenever shipped functionality, architecture expectations, deployment behavior, or operator-facing workflows materially change.
|
||||
This file is the running release and change log for CODEXIUM. Keep it updated whenever shipped functionality, architecture expectations, deployment behavior, or operator-facing workflows materially change.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Reverse project visibility on quote and sales-order detail pages, purchase-order header project linkage, sales-order-driven project auto-derivation for new work orders and purchase orders, quote-to-sales-order project carry-through during conversion, and migration backfill for existing work orders and purchase orders linked through project sales orders
|
||||
- Finance module with customer-payment posting against sales orders, finance costing assumptions, sales-order cash/spend ledger rollups, manufacturing cost snapshots, and CapEx tracking for equipment, tooling, and consumables
|
||||
- Inventory-backed shipment picking from shipment detail pages, including sales-order line remaining-quantity visibility, warehouse/location source selection, issued-stock posting, and shipment pick history
|
||||
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline
|
||||
- Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer
|
||||
- Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions
|
||||
- Manufacturing finite-capacity slice with station daily capacity, parallel capacity, working-day calendars, calendar-aware operation scheduling, and operation-level rescheduling from the work-order detail page
|
||||
- Manufacturing station edit support for working days, active state, queue, and capacity settings directly from the manufacturing screen
|
||||
- Operation execution controls on work orders, including start/pause/resume/complete actions, labor posting, and actual-minute rollups by operation and work order
|
||||
- Operation operator assignment and timer-based labor capture, with timer stop posting elapsed minutes back as labor entries
|
||||
- Workbench rebalance controls for operation rows, including planner-side datetime rescheduling, quick shift moves, and heatmap-day targeting without leaving the dispatch surface
|
||||
- Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface
|
||||
- Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed
|
||||
- Workbench station cards now show planned-vs-actual load so planners can compare schedule intent against recorded execution time
|
||||
- Work-order `On Hold` quick status changes now require a recorded hold reason and persist the active blocker on the work-order record and audit trail
|
||||
- Project milestone cards now support inline quick status actions for start, block, complete, reset, and reopen flows directly from the project detail view
|
||||
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
|
||||
- UI density standardization pass across app shell, dashboard, finance, project detail, manufacturing detail, and admin surfaces, including tighter panel spacing, more compact shell/navigation spacing, and removal of redundant explanatory subcopy in favor of concise uppercase section labels
|
||||
- Continued density standardization across CRM, inventory, sales, purchasing, and shipping list/detail surfaces so module headers, filter bars, and status panels follow the same tighter uppercase operational pattern
|
||||
- Continued density standardization across CRM, sales, purchasing, shipping, manufacturing, and project form/detail headers so editor and record surfaces now follow the same compact uppercase pattern with less redundant helper copy
|
||||
- Continued density standardization across CRM detail internals and inventory item editing so secondary cards, timeline/history panels, thumbnail panels, BOM/routing editors, and empty states use the tighter shared surface treatment with less filler copy
|
||||
- Continued density standardization across inventory detail transaction/transfer/reservation surfaces, and fixed item-editor navigation controls so SKU master and cancel actions navigate reliably from the create-item form
|
||||
- Continued density standardization across sales, purchasing, shipping, and manufacturing editor internals, and standardized form-header cancel actions onto button-driven navigation to avoid in-form route-transition edge cases
|
||||
- Continued density standardization across sales, purchasing, shipping, and manufacturing detail internals, including denser KPI strips, tighter side panels, shorter empty states, and less redundant context copy on high-traffic record views
|
||||
- Continued density standardization across shared attachment and revision-comparison surfaces, and changed inventory item-editor exit actions to hard navigation so SKU master and cancel transitions no longer depend on client-side router state
|
||||
- Continued density standardization across the SKU master builder and planning workbench, including tighter tree and board panels, denser exception and focus surfaces, shorter empty states, and less helper copy on those operational screens
|
||||
- Continued density standardization across warehouse list/detail/editor screens and the manufacturing station surface, including tighter status blocks, denser location/station cards, and removal of older roomy header patterns
|
||||
- Continued density standardization across company settings and deeper manufacturing detail surfaces, including tighter admin/profile/theme sections, denser work-order execution panels, and compact issue/completion history cards
|
||||
- Continued density standardization across project cockpit/detail internals, including tighter cockpit cards, denser purchasing and readiness panels, and compact milestone, manufacturing-link, and activity-timeline surfaces
|
||||
- Continued density standardization across admin diagnostics, user management, and CRM contacts, including tighter filter/forms, denser summary cards, and compact contact/account management surfaces
|
||||
- Workbench usability pass with sticky planner controls, stronger selected-row and selected-day state, clearer heatmap/day context, and more explicit dispatch-oriented action affordances
|
||||
- Workbench usability depth with keyboard row navigation, enter-to-open behavior, escape-to-clear, and inline readiness/shortage/hold signal pills across planner rows and day-detail cards
|
||||
- Workbench dispatch workflow depth with saved planner views, a release queue for visible ready work, queued-record visibility in the sticky control bar, and batch release directly from the workbench
|
||||
- Workbench batch operation rebalance with multi-operation selection, sticky-bar batch reschedule controls, station reassignment across selected operations, and selected-operation visibility in row signals and focus context
|
||||
- Workbench conflict-intelligence pass with projected batch target load, overload warnings before batch station moves, and best-alternate-station suggestions inside the sticky rebalance controls
|
||||
- Workbench date-aware slot guidance using station working-day calendars and queue settings to suggest the next workable batch landing dates directly from the sticky rebalance controls
|
||||
- Planning timeline now includes station day-load rollups, and Workbench slot suggestions use that server-backed per-day capacity data instead of only summary-level utilization heuristics
|
||||
- Workbench now surfaces day-level capacity directly in the planner, including hot-station day counts on heatmap cells, selected-day station load breakdowns, and per-station hot-day chips in station grouping mode
|
||||
- Workbench exception prioritization now scores and ranks projects, work orders, agenda rows, and dispatch exceptions by lateness, blockage, shortage, readiness, and overload pressure, with inline priority chips for faster triage
|
||||
- Workbench now surfaces top-priority action lanes for `DO NOW`, `UNBLOCK`, and `RELEASE READY` records so planners can jump straight into ranked dispatch queues before working deeper lists
|
||||
- Workbench action lanes now support direct follow-through from the lane cards themselves, including queue-release and the first inline build/buy/open actions without requiring a second step into the focus drawer
|
||||
- Project-side milestone and work-order rollups surfaced on project list and detail pages
|
||||
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
|
||||
- Dedicated inventory-item thumbnail display on the item detail page
|
||||
- Revision comparison views for sales quotes, sales orders, and purchase orders with field- and line-level before/after diffs
|
||||
- Purchase-order revision snapshots covering document edits, status changes, and receipt posting
|
||||
- Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings
|
||||
- Session filters and text search for admin-side access review across user, email, IP, user agent, and review reasons
|
||||
- Support-log filtering by severity, source, search text, and retention window in admin diagnostics
|
||||
- Support-log export and support-snapshot export now carry filter context, summary counts, available sources, and retention metadata
|
||||
- Shared destructive-action confirmation dialog with impact and recovery guidance for high-risk operational actions
|
||||
- Typed confirmation for sensitive admin actions such as account deactivation, current-session revocation, and terminal manufacturing/inventory postings
|
||||
- Destructive-action confirmation and recovery coverage for sales approvals, quote conversion, purchase receiving, purchase status changes, and shipment status changes
|
||||
- Destructive-action confirmation coverage for project customer/document unlinking and embedded form-row removals in sales, purchasing, inventory, and warehouse editors
|
||||
- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins
|
||||
- Admin-side session revocation controls plus server-side logout that invalidates the current JWT-backed session
|
||||
- Shared shortage and readiness rollups across dashboard, planning, project detail, purchasing detail, and manufacturing detail
|
||||
- Prefilled work-order draft launch for build recommendations and prefilled purchase-order draft launch for buy recommendations from sales-order demand planning
|
||||
- Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children
|
||||
- Netting of sales-order demand against available stock, active reservations, open work orders, and open purchase orders
|
||||
- Build and buy recommendations surfaced directly on sales-order detail pages
|
||||
- Pegged work-order and purchase-order supply tracking back to sales demand so reopened planning views do not overstate remaining recommendations
|
||||
- Preferred-vendor sourcing on inventory items, used to preseed buy-side planning workflows
|
||||
- Demand-planning recommendations now reduce against existing linked WO/PO supply and support safer partial conversion behavior
|
||||
- Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics
|
||||
- Exportable support bundles that now include backup guidance and recent support logs for support handoff
|
||||
- Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups
|
||||
- Backup verification checklist and restore-drill runbook surfaced in admin diagnostics
|
||||
- Backup/restore guidance surfaced in admin diagnostics with exportable support snapshot JSON for support handoff
|
||||
- CRM customer/vendor changes and shipping mutations now feed the shared audit trail
|
||||
- Startup validation now runs during server boot and surfaces readiness checks in admin diagnostics
|
||||
- Admin user-management screen with account creation, activation control, role assignment, and role-permission editing
|
||||
- Route-level lazy loading and vendor chunking across the client so major operational modules no longer ship in the initial bundle
|
||||
- Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
|
||||
- Admin diagnostics page with runtime footprint, storage-path visibility, key record counts, and recent audit activity
|
||||
- Inventory transfers with paired physical stock movement posting between warehouses and locations
|
||||
- Manual inventory reservations plus automatic work-order-driven component reservations
|
||||
- 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
|
||||
- Automatic work-order operation plans copied from buildable item routing into planning/gantt
|
||||
- Live planning gantt timelines backed by active projects and open manufacturing work orders
|
||||
- Automatic work-order operation plans copied from buildable item routing into the planning workbench
|
||||
- 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
|
||||
- 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
|
||||
@@ -27,9 +103,22 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
||||
|
||||
### Changed
|
||||
|
||||
- Shipping-label PDFs now render inside an explicit single-page 4x6 canvas with tighter print-safe spacing and overflow-safe text wrapping to prevent second-sheet runover on label printers
|
||||
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
|
||||
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
|
||||
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
|
||||
- Admin user edits now normalize blank passwords to null and surface save failures instead of appearing unresponsive
|
||||
- Fresh bootstrap now creates only minimal system records; operational domains no longer start with seeded demo customers, vendors, inventory, BOMs, or warehouses
|
||||
- Sales and purchasing detail pages now expose revision comparison directly alongside chronological revision history
|
||||
- `ROADMAP.md` now tracks remaining work only, and shipped phase history now lives in `SHIPPED.md`
|
||||
- Support logs now prune retained entries by age instead of only trimming by count, and admin diagnostics now reviews filtered support-log summaries instead of an unbounded flat dump
|
||||
- Admin diagnostics now summarizes sessions that need review, and startup now prunes old expired or revoked auth-session records
|
||||
- Admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse, and attachment workflows now use explicit destructive-action confirmation and recovery messaging instead of immediate irreversible clicks
|
||||
- Admin operations now combine user management with live session visibility so operators can inspect and revoke sign-ins without changing user records
|
||||
- JWT authentication now validates against persisted session records and inactive users lose access immediately instead of waiting for token expiry
|
||||
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
|
||||
- The dashboard now treats 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
|
||||
- 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
|
||||
@@ -37,7 +126,14 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
||||
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
|
||||
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
|
||||
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
|
||||
- Roadmap and project docs now treat broader audit-trail coverage and operational diagnostics as the next active priority after the inventory-control slice
|
||||
- The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx`
|
||||
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
|
||||
- Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity
|
||||
- Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging
|
||||
- Roadmap and project docs now treat demand planning and supply generation as its own phase ahead of the deferred admin QOL work
|
||||
- Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice
|
||||
- Manufacturing work-order material requirements now include live available/shortage visibility instead of only required-versus-issued math
|
||||
- Roadmap and project docs now move back to admin session visibility and destructive-action safety after the demand-planning rollout
|
||||
|
||||
## 2026-03-15
|
||||
|
||||
@@ -96,3 +192,4 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
||||
- Vendor invoice/supporting-document attachments
|
||||
- Sales approvals and document revision history
|
||||
- Projects, manufacturing execution, and planning depth
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Documentation maintenance
|
||||
|
||||
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated whenever shipped functionality, architecture expectations, deployment behavior, or user-facing workflows materially change.
|
||||
- If a change invalidates [README.md](D:/CODING/mrp-codex/README.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), or [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), update those files in the same change set.
|
||||
- If a change invalidates [README.md](D:/CODING/mrp-codex/README.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md), or [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), update those files in the same change set.
|
||||
|
||||
## Current milestone
|
||||
|
||||
@@ -16,17 +16,32 @@ This repository implements the platform foundation milestone:
|
||||
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
|
||||
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
|
||||
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
||||
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
|
||||
- inventory thumbnail image staging on create/edit and dedicated thumbnail display on item detail
|
||||
- sales quotes and sales orders with quick actions and quote conversion
|
||||
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders
|
||||
- sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
|
||||
- purchase orders with quick actions and searchable vendor/SKU entry
|
||||
- purchase orders restricted to inventory items flagged as purchasable
|
||||
- purchase receiving foundation with inventory posting and receipt history
|
||||
- purchase-order revision history and revision comparison across document and receipt changes
|
||||
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline
|
||||
- purchase-order supporting documents and vendor-side purchasing visibility
|
||||
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
|
||||
- projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility
|
||||
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility
|
||||
- planning gantt timelines backed by live project and manufacturing schedule data
|
||||
- sales-order demand planning with multi-level BOM explosion, net stock/open-supply coverage, and build/buy recommendations
|
||||
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
||||
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
|
||||
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
|
||||
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
|
||||
- admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, and review filtering
|
||||
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
|
||||
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
||||
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
|
||||
- backup verification checklist and restore-drill runbook in diagnostics
|
||||
- support-log viewing, filtering, retention cleanup, and support debugging helpers in diagnostics
|
||||
- startup brand-theme hydration so Company Settings colors persist correctly across refresh
|
||||
- Dockerized single-container deployment
|
||||
- Puppeteer PDF pipeline foundation
|
||||
|
||||
@@ -40,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.
|
||||
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.
|
||||
9. Treat the landing page as `Dashboard`: a metric-oriented, modular command surface that should accumulate reusable operational panels over time.
|
||||
10. Purchase-order item selection must be restricted to inventory items where `isPurchasable = true`.
|
||||
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.
|
||||
12. Keep `Projects`, `Manufacturing`, and `Planning` distinct: projects are long-running program records, manufacturing is execution, and planning is scheduling/visibility.
|
||||
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.
|
||||
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. When designing operational pages, bias toward information density: tighter panel padding, smaller stack gaps, and fewer explanatory filler blocks.
|
||||
11. Treat the landing page as `Dashboard`: a metric-oriented, modular command surface that should accumulate reusable operational panels over time.
|
||||
12. Purchase-order item selection must be restricted to inventory items where `isPurchasable = true`.
|
||||
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
|
||||
|
||||
@@ -62,5 +79,6 @@ This repository implements the platform foundation milestone:
|
||||
|
||||
## Next roadmap candidates
|
||||
|
||||
- broader audit and operations maturity
|
||||
- code-splitting and bundle-size reduction
|
||||
- project milestones and project-side rollup visibility
|
||||
- manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||
|
||||
|
||||
526
MARKET.md
Normal file
526
MARKET.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# MARKET
|
||||
|
||||
## Purpose
|
||||
|
||||
This document compares CODEXIUM against the provided top-50 requested manufacturing MRP features and translates the gaps into a practical product roadmap.
|
||||
|
||||
Assessment basis:
|
||||
|
||||
- current shipped scope in [README.md](D:/CODING/mrp-codex/README.md)
|
||||
- completed work in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md)
|
||||
- planned work in [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md)
|
||||
- current repository implementation state as of March 17, 2026
|
||||
|
||||
Status legend:
|
||||
|
||||
- `Implemented`: materially present in the product today
|
||||
- `Partial`: meaningful foundation exists, but the market-expected version is not complete
|
||||
- `Missing`: not present or not competitive enough to count as delivered
|
||||
|
||||
## Executive Summary
|
||||
|
||||
CODEXIUM is already strong for a foundation-stage MRP in these areas:
|
||||
|
||||
- integrated CRM, sales, purchasing, shipping, inventory, projects, manufacturing, and planning in one app
|
||||
- multi-level BOM explosion for demand planning
|
||||
- inventory reservations, transfers, and location-level stock visibility
|
||||
- work orders, stations, operation templates, material issue/completion posting
|
||||
- pegged build/buy recommendations tied back to sales demand
|
||||
- project linkage across customer, commercial, shipment, and manufacturing records
|
||||
- branded documents, attachments, audit trail, diagnostics, RBAC, and single-container deployment
|
||||
|
||||
CODEXIUM is not yet market-complete against the top-50 list. The current position is:
|
||||
|
||||
- `Implemented`: 3 / 50
|
||||
- `Partial`: 13 / 50
|
||||
- `Missing`: 34 / 50
|
||||
|
||||
That result is not a criticism of the product direction. It reflects that CODEXIUM is already a credible modular MRP foundation, but has not yet expanded into the deeper planning, traceability, execution, maintenance, quality, integration, and analytics layers expected by mature manufacturing buyers.
|
||||
|
||||
## Tier Summary
|
||||
|
||||
| Tier | Focus | Implemented | Partial | Missing | Readout |
|
||||
| --- | --- | ---: | ---: | ---: | --- |
|
||||
| Tier 1 | Core planning & scheduling | 0 | 3 | 7 | Good demand-planning base, but no full MPS/capacity/ATP engine yet |
|
||||
| Tier 2 | Inventory & traceability | 3 | 0 | 7 | Strong stock visibility baseline, weak traceability/compliance depth |
|
||||
| Tier 3 | Production execution & quality | 0 | 3 | 12 | Work-order foundation exists, but execution depth is still early |
|
||||
| Tier 4 | Integration & interoperability | 0 | 3 | 7 | Internal modules are connected; external ecosystem integration is mostly absent |
|
||||
| Tier 5 | Analytics, UX & platform quality | 0 | 4 | 1 | Strong UX intent and dashboard base, but not yet configurable/mobile/AI-driven |
|
||||
|
||||
## Comparative Analysis
|
||||
|
||||
### Tier 1: Core Planning & Scheduling
|
||||
|
||||
| Rank | Feature | Status | CODEXIUM Position |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Purchase Planning & Automated PO Generation | `Partial` | Demand-planning recommendations and prefilled PO drafts exist, but not full autonomous purchasing from reorder points, lead times, min/max, and policy-driven replenishment. |
|
||||
| 2 | Demand Forecasting from Historical Data | `Missing` | No historical demand forecasting engine or forecast-driven planning model is present. |
|
||||
| 3 | Master Production Scheduling (MPS) | `Missing` | Planning is driven by live project/work-order/demand views, but there is no explicit top-level MPS layer. |
|
||||
| 4 | Finite Capacity Scheduling | `Missing` | Current planning is not capacity-aware and does not enforce machine/labor constraints. |
|
||||
| 5 | Multi-Level BOM Management | `Partial` | Multi-level BOM structure and explosion exist, but BOM versioning, ECO-driven effectivity, and parametric relationships are missing. |
|
||||
| 6 | Sales Order to Production Job Auto-Conversion | `Partial` | Planner-assisted conversion to work-order drafts exists, but not true automatic conversion from confirmed sales demand into planned/released jobs. |
|
||||
| 7 | Scenario Planning & Simulation | `Missing` | No what-if modeling or alternate-plan simulation is present. |
|
||||
| 8 | Multi-Site / Multi-Plant Planning | `Missing` | Warehouses/locations exist, but not plant-aware planning, transfer planning, or site capacity coordination. |
|
||||
| 9 | Subcontract / Outsourced Operation Management | `Missing` | No subcontracted operation flow, outside processing routing step, or supplier-linked manufacturing handoff exists. |
|
||||
| 10 | Delivery Date Estimation (Available-to-Promise) | `Missing` | No ATP/CTP calculation is available for quoting or customer-service commitments. |
|
||||
|
||||
### Tier 2: Inventory & Traceability
|
||||
|
||||
| Rank | Feature | Status | CODEXIUM Position |
|
||||
| --- | --- | --- | --- |
|
||||
| 11 | Real-Time Inventory Visibility Across All Locations | `Implemented` | On-hand, reserved, available, warehouse/location balances, transfers, and transaction history are shipped. |
|
||||
| 12 | Lot / Serial Number Tracking (Full Bi-Directional) | `Missing` | No lot genealogy, serial trace, or forward/backward traceability is implemented. |
|
||||
| 13 | Inventory Reservation & Allocation | `Implemented` | Manual reservations and work-order-driven reservations are already present. |
|
||||
| 14 | Barcode / RFID Scanning Support | `Missing` | No scanner-first receiving, picking, issue, completion, or count workflow exists. |
|
||||
| 15 | Negative Stock Prevention Enforcement | `Missing` | No explicit stock-floor enforcement is documented or surfaced as a guarded planning/inventory rule. |
|
||||
| 16 | FIFO / FEFO Inventory Consumption Rules | `Missing` | Inventory issue logic does not yet expose consumption policies by lot/date/age. |
|
||||
| 17 | Recall Readiness & <2-Minute Trace Report | `Missing` | Without lot genealogy, recall impact reporting is not possible. |
|
||||
| 18 | Multi-Location Inventory Management | `Implemented` | Warehouses and nested stock locations are already core to the inventory module. |
|
||||
| 19 | Inventory Cycle Count & Physical Inventory Portals | `Missing` | No cycle-count workflow, count scheduling, or limited-access count portal exists. |
|
||||
| 20 | Consignment & Vendor-Managed Inventory (VMI) | `Missing` | No supplier-owned stock model or consumption-based supplier settlement is present. |
|
||||
|
||||
### Tier 3: Production Execution & Quality
|
||||
|
||||
| Rank | Feature | Status | CODEXIUM Position |
|
||||
| --- | --- | --- | --- |
|
||||
| 21 | Digital Work Orders with Shop Floor Dispatch | `Partial` | Digital work orders exist, but dispatch, operator queueing, and workstation-first execution are still limited. |
|
||||
| 22 | Real-Time Job Costing (Material + Labor + Overhead) | `Missing` | Material posting exists, but labor, overhead, and real-time job-cost accumulation do not. |
|
||||
| 23 | Time & Labor Tracking by Job / Operation | `Missing` | No labor clock-in/out or operation time capture exists. |
|
||||
| 24 | Work Center Capacity & Load Planning | `Partial` | Station master data and planning views exist, but no true work-center load balancing or capacity board is delivered. |
|
||||
| 25 | Production Cost Reporting (Estimate vs. Actual) | `Missing` | No estimate-vs-actual job costing or variance analysis is currently present. |
|
||||
| 26 | Paperless Shop Floor with Digital Work Instructions | `Partial` | Attachments exist on items and work orders, but not a dedicated operator instruction experience with current-revision control. |
|
||||
| 27 | Quality Inspection Integration (Inline & Final) | `Missing` | No inspection plans, in-process checks, or work-order quality gate flow exists. |
|
||||
| 28 | Non-Conformance (NCR) Tracking & CAPA Workflow | `Missing` | No NCR/CAPA subsystem exists. |
|
||||
| 29 | Preventive Maintenance Scheduling (EAM) | `Missing` | No asset maintenance module or PM scheduler exists. |
|
||||
| 30 | Machine & Equipment Integration (MES connectivity) | `Missing` | No machine data collection, cycle reporting, or automated shop-floor feedback loop exists. |
|
||||
| 31 | Configure-Price-Quote (CPQ) for Engineer-to-Order | `Missing` | No product configurator, rules engine, or auto-BOM generation for configured products exists. |
|
||||
| 32 | Subcontractor Material Free-Issue Tracking | `Missing` | No supplier-side issued-material reconciliation exists for outsourced operations. |
|
||||
| 33 | Scrap & Yield Management | `Missing` | No expected vs actual yield, scrap capture, or material-adjusted production reporting exists. |
|
||||
| 34 | Engineering Change Order (ECO) Management | `Missing` | Roadmap references deeper revision needs, but no ECO workflow is implemented. |
|
||||
| 35 | Tooling & Fixture Tracking | `Missing` | No tooling availability, maintenance, or routing dependency model exists. |
|
||||
|
||||
### Tier 4: Integration & Interoperability
|
||||
|
||||
| Rank | Feature | Status | CODEXIUM Position |
|
||||
| --- | --- | --- | --- |
|
||||
| 36 | Native ERP / Accounting Integration (Bidirectional) | `Missing` | No bidirectional accounting or ERP sync layer exists. |
|
||||
| 37 | CRM / Sales Order Integration | `Partial` | CRM and sales are natively integrated inside CODEXIUM, but not synced bidirectionally to external CRM systems. |
|
||||
| 38 | EDI Support (ASC X12 / EDIFACT) | `Missing` | No EDI transaction support is present. |
|
||||
| 39 | 3PL / WMS Integration (API + EDI 940/945/856) | `Missing` | Shipping documents exist, but there is no 3PL/WMS integration layer. |
|
||||
| 40 | IoT / Sensor Data Integration for Predictive Maintenance | `Missing` | No IoT ingestion or predictive maintenance trigger path exists. |
|
||||
| 41 | Supplier Portal with Lead-Time & Performance Tracking | `Missing` | Vendors are modeled internally, but there is no external supplier portal or ASN/confirmation workflow. |
|
||||
| 42 | eCommerce Platform Integration | `Missing` | No Shopify/WooCommerce or marketplace sync exists. |
|
||||
| 43 | Open REST API with Webhooks | `Partial` | Internal REST APIs exist, but not a documented public integration surface with webhooks and versioning guarantees. |
|
||||
| 44 | CAD / PDM System Integration | `Missing` | No engineering-system import pipeline exists for BOMs or revisions. |
|
||||
| 45 | Accounting / Tax Localization (Multi-Country) | `Partial` | Currency/tax fields exist, but not country-specific tax logic, fiscal localization, or e-invoicing support. |
|
||||
|
||||
### Tier 5: Analytics, UX & Platform Quality
|
||||
|
||||
| Rank | Feature | Status | CODEXIUM Position |
|
||||
| --- | --- | --- | --- |
|
||||
| 46 | Configurable Role-Based Dashboards with Real-Time KPIs | `Partial` | Dashboard widgets and rollups exist, but dashboards are not yet user-role configurable. |
|
||||
| 47 | AI-Powered Planning Anomaly Detection | `Missing` | No AI/ML anomaly engine exists in planning. |
|
||||
| 48 | Mobile-First Shop Floor Interface | `Partial` | The app is responsive, but not intentionally designed as a mobile-first execution surface for shop-floor work. |
|
||||
| 49 | Project Capabilities (Cradle-to-Grave Project Tracking) | `Partial` | Projects are a first-class module and already linked across CRM, sales, shipping, and manufacturing, but milestones, cost rollups, procurement depth, and full cradle-to-grave control are still roadmap items. |
|
||||
| 50 | Intuitive UX with Low Onboarding Time | `Partial` | Searchable pickers, dense operational layouts, and modular navigation are good foundations, but the product still needs more guided workflows and role-tailored experiences to claim this as a true market strength. |
|
||||
|
||||
## What CODEXIUM Already Does Well
|
||||
|
||||
These are the strongest market-facing talking points today:
|
||||
|
||||
1. End-to-end modular manufacturing workflow already exists in one codebase.
|
||||
2. Demand planning is deeper than a typical early-stage MRP because it already supports BOM explosion, pegged supply, and build/buy recommendations.
|
||||
3. Inventory location visibility, reservations, and transfer flows are already operational.
|
||||
4. Projects are already first-class, which is a major differentiator versus many ERP foundations.
|
||||
5. Work orders already post real inventory transactions for issue and completion.
|
||||
6. Documents, approvals, revisions, attachments, audit, diagnostics, and brandable PDFs are unusually mature for this product stage.
|
||||
7. Single-container deployment and SQLite-backed portability make the system operationally simple for smaller manufacturers.
|
||||
|
||||
## Missing Features and How To Achieve Them
|
||||
|
||||
The list below focuses on practical product moves rather than abstract wish-list items.
|
||||
|
||||
### 1. Deepen Planning Into A Real Scheduling Engine
|
||||
|
||||
Missing or weak features:
|
||||
|
||||
- demand forecasting
|
||||
- master production scheduling
|
||||
- finite capacity scheduling
|
||||
- scenario planning
|
||||
- ATP / delivery-date estimation
|
||||
- multi-site planning
|
||||
|
||||
How to achieve it:
|
||||
|
||||
- Extend `planning` into a true planning engine instead of a gantt-only visibility layer.
|
||||
- Add `forecast` entities and time buckets under `server/src/modules/planning`.
|
||||
- Add `workCenter`, `calendar`, `shift`, `capacityBucket`, and `constraint` models under `manufacturing`.
|
||||
- Build an MPS layer that sits between sales/project demand and detailed work-order generation.
|
||||
- Add an ATP service that evaluates open supply, capacity, and purchased lead times before returning a promise date.
|
||||
- Expand the current demand-planning recommendation engine to support reorder policies, time fences, and alternate scenarios.
|
||||
|
||||
Recommended roadmap placement:
|
||||
|
||||
- Near-term after current manufacturing routing/work-center work
|
||||
- Belongs mostly in existing `planning` and `manufacturing` modules
|
||||
|
||||
### 2. Make Inventory Planning-Grade and Recall-Grade
|
||||
|
||||
Missing or weak features:
|
||||
|
||||
- lot/serial traceability
|
||||
- negative stock prevention
|
||||
- FIFO/FEFO rules
|
||||
- cycle counting
|
||||
- barcode workflows
|
||||
- recall reporting
|
||||
- consignment / VMI
|
||||
|
||||
How to achieve it:
|
||||
|
||||
- Add lot and serial models, lot-controlled transactions, and lot-resolved reservations under `inventory`.
|
||||
- Enforce non-negative stock in issue/transfer/reservation services, not just in UI validation.
|
||||
- Add inventory policy settings per item: lot-controlled, serial-controlled, FIFO, FEFO, expiry, consignment.
|
||||
- Build count-session entities and lightweight count-task screens for warehouse execution.
|
||||
- Introduce barcode-first receiving/issue/transfer/count UI states in the inventory and manufacturing modules.
|
||||
- Once lot genealogy exists, add trace reports for backward/forward lot resolution and recall impact.
|
||||
|
||||
Recommended roadmap placement:
|
||||
|
||||
- High priority if targeting food, medical, chemical, or aerospace-adjacent manufacturers
|
||||
- Belongs in `inventory`, with trace cross-links into `manufacturing`, `purchasing`, and `shipping`
|
||||
|
||||
### 3. Expand Manufacturing From Work Orders Into Shop-Floor Execution
|
||||
|
||||
Missing or weak features:
|
||||
|
||||
- labor tracking
|
||||
- job costing
|
||||
- estimate-vs-actual cost reporting
|
||||
- scrap/yield
|
||||
- digital instructions
|
||||
- quality checkpoints
|
||||
- NCR/CAPA
|
||||
- tooling
|
||||
- subcontract operations
|
||||
|
||||
How to achieve it:
|
||||
|
||||
- Extend `manufacturing` with `workCenter`, `routingStep`, `laborEntry`, `machineTime`, `scrapEvent`, `yieldSnapshot`, and `qualityInspection`.
|
||||
- Add operation-level start/pause/complete execution screens and operator queue views.
|
||||
- Add cost-rollup services that combine issued material, labor time, and burden rates.
|
||||
- Reuse attachments plus revision metadata to create a formal digital instruction panel on the work-order operation screen.
|
||||
- Add NCR/CAPA and inspection models either under `manufacturing` or a new `quality` module.
|
||||
- Add outside-processing routing steps that generate supplier-facing purchasing events.
|
||||
|
||||
Recommended roadmap placement:
|
||||
|
||||
- Direct extension of the current manufacturing roadmap
|
||||
- Best implemented as deeper `manufacturing` capability, with a likely new `quality` subdomain
|
||||
|
||||
### 4. Add Engineering Change and Product-Control Depth
|
||||
|
||||
Missing or weak features:
|
||||
|
||||
- BOM version control
|
||||
- ECO workflow
|
||||
- CAD/PDM integration
|
||||
- configurable products / CPQ
|
||||
- tooling dependencies
|
||||
|
||||
How to achieve it:
|
||||
|
||||
- Add revision-controlled BOM headers and effectivity dates in `inventory`.
|
||||
- Add ECO request, approval, release, and effective-change propagation into BOM/routing/work-order data.
|
||||
- Build import connectors for engineering structures from CAD/PDM exports first, then native integrations later.
|
||||
- Introduce configurable product rules in `sales` plus generated BOM/routing outcomes in `inventory` and `manufacturing`.
|
||||
|
||||
Recommended roadmap placement:
|
||||
|
||||
- Mid-term
|
||||
- Primarily `inventory`, `sales`, and `manufacturing`
|
||||
|
||||
### 5. Build the Integration Surface Buyers Expect
|
||||
|
||||
Missing or weak features:
|
||||
|
||||
- accounting integration
|
||||
- supplier portal
|
||||
- EDI
|
||||
- 3PL / WMS integration
|
||||
- eCommerce sync
|
||||
- public API + webhooks
|
||||
- IoT maintenance feeds
|
||||
|
||||
How to achieve it:
|
||||
|
||||
- Formalize the current internal API into a versioned integration layer.
|
||||
- Add outbound webhook events for order approved, PO created, receipt posted, WO released, shipment shipped, inventory changed.
|
||||
- Add integration adapters under a new `integrations` module rather than burying them in domain services.
|
||||
- Start with REST + webhooks and CSV import/export for fastest market progress.
|
||||
- Follow with targeted adapters: QuickBooks/Xero, Shopify, 3PL APIs, supplier confirmations/ASN, and later EDI.
|
||||
|
||||
Recommended roadmap placement:
|
||||
|
||||
- Mid-term, but public API/webhooks should move earlier because they unblock partner/customer integrations
|
||||
- Best as a new top-level backend domain: `server/src/modules/integrations`
|
||||
|
||||
### 6. Turn Projects Into A Real ETO / Program-Control Advantage
|
||||
|
||||
Missing or weak features:
|
||||
|
||||
- milestones
|
||||
- project rollups
|
||||
- project-level costs
|
||||
- project material view
|
||||
- project execution cockpit
|
||||
|
||||
How to achieve it:
|
||||
|
||||
- Deliver the already-planned milestones and rollups first.
|
||||
- Add project-level commercial, supply, manufacturing, and shipment summary services.
|
||||
- Add project cost buckets sourced from sales, purchasing, manufacturing, and shipping transactions.
|
||||
- Add project readiness and shortage boards for long-running builds.
|
||||
|
||||
Recommended roadmap placement:
|
||||
|
||||
- Immediate priority
|
||||
- Extends the existing `projects` module and strengthens one of CODEXIUM’s best differentiators
|
||||
|
||||
### 7. Improve Daily Adoption Through Role-Specific UX
|
||||
|
||||
Missing or weak features:
|
||||
|
||||
- configurable dashboards
|
||||
- mobile-first shop-floor screens
|
||||
- guided low-onboarding workflows
|
||||
- anomaly detection
|
||||
|
||||
How to achieve it:
|
||||
|
||||
- Add saved dashboard layouts and role-default widget packs under `dashboard`.
|
||||
- Build dedicated mobile execution screens for receiving, issue, completion, counts, and labor entry.
|
||||
- Add contextual “next step” actions and exception queues per role instead of generic list/detail flows only.
|
||||
- Add rule-based anomaly detection first, then ML later: late PO risk, overloaded work center, missing material, demand spike, suspicious lead-time drift.
|
||||
|
||||
Recommended roadmap placement:
|
||||
|
||||
- Split into near-term UX work and later AI work
|
||||
- `dashboard`, `inventory`, `manufacturing`, and `planning` should all contribute
|
||||
|
||||
## Suggested Product Roadmap From This Analysis
|
||||
|
||||
### Phase A: Convert Foundation MRP Into Planning-Grade MRP
|
||||
|
||||
Priority additions:
|
||||
|
||||
- project milestones and project rollups
|
||||
- work-center and routing depth
|
||||
- labor capture
|
||||
- capacity-aware scheduling
|
||||
- reorder-policy purchasing
|
||||
- non-negative stock enforcement
|
||||
- BOM revisions
|
||||
|
||||
Why this phase matters:
|
||||
|
||||
- It strengthens current modules without requiring new market-facing verticals.
|
||||
- It closes the biggest credibility gaps in planning and manufacturing execution.
|
||||
|
||||
### Phase B: Make The System Traceable and Shop-Floor Ready
|
||||
|
||||
Priority additions:
|
||||
|
||||
- lot/serial traceability
|
||||
- barcode workflows
|
||||
- cycle counting
|
||||
- digital operator dispatch
|
||||
- scrap/yield
|
||||
- quality inspections
|
||||
- job costing
|
||||
|
||||
Why this phase matters:
|
||||
|
||||
- It moves CODEXIUM from “good operational foundation” to “usable manufacturing system” for more serious buyers.
|
||||
|
||||
### Phase C: Build The ETO / Multi-Discipline Differentiator
|
||||
|
||||
Priority additions:
|
||||
|
||||
- project cockpit
|
||||
- ECO workflow
|
||||
- estimate-vs-actual project/job cost visibility
|
||||
- subcontract processing
|
||||
- ATP
|
||||
- scenario planning
|
||||
|
||||
Why this phase matters:
|
||||
|
||||
- It leans into CODEXIUM’s strongest differentiator: projects + manufacturing + planning in one product.
|
||||
|
||||
### Phase D: Open The Platform
|
||||
|
||||
Priority additions:
|
||||
|
||||
- versioned public REST API
|
||||
- webhooks
|
||||
- accounting integration
|
||||
- supplier portal
|
||||
- 3PL integration
|
||||
- eCommerce sync
|
||||
|
||||
Why this phase matters:
|
||||
|
||||
- It improves commercial viability and reduces objections in buyer evaluations.
|
||||
|
||||
## Feature Gap Analysis By Manufacturing Type
|
||||
|
||||
### Make-to-Stock (MTS)
|
||||
|
||||
Current fit:
|
||||
|
||||
- moderate
|
||||
|
||||
Strengths:
|
||||
|
||||
- stock visibility
|
||||
- reservations
|
||||
- transfers
|
||||
- purchasing and sales document flow
|
||||
|
||||
Biggest gaps:
|
||||
|
||||
- forecasting
|
||||
- reorder automation
|
||||
- FIFO/FEFO
|
||||
- count discipline
|
||||
|
||||
### Make-to-Order (MTO)
|
||||
|
||||
Current fit:
|
||||
|
||||
- moderate to strong
|
||||
|
||||
Strengths:
|
||||
|
||||
- order-driven demand planning
|
||||
- quote/order/project/manufacturing linkage
|
||||
- shipment linkage
|
||||
|
||||
Biggest gaps:
|
||||
|
||||
- ATP
|
||||
- job costing
|
||||
- labor tracking
|
||||
- deeper production dispatch
|
||||
|
||||
### Configure-to-Order (CTO)
|
||||
|
||||
Current fit:
|
||||
|
||||
- weak
|
||||
|
||||
Biggest gaps:
|
||||
|
||||
- CPQ
|
||||
- revision-controlled engineering changes
|
||||
- CAD/PDM integration
|
||||
|
||||
### Engineer-to-Order (ETO)
|
||||
|
||||
Current fit:
|
||||
|
||||
- moderate foundation, high upside
|
||||
|
||||
Strengths:
|
||||
|
||||
- projects are already first-class
|
||||
- commercial/manufacturing/shipping cross-links already exist
|
||||
|
||||
Biggest gaps:
|
||||
|
||||
- milestones
|
||||
- cost rollups
|
||||
- ECO
|
||||
- subcontracting
|
||||
- CPQ/configuration logic
|
||||
|
||||
### Process Manufacturing
|
||||
|
||||
Current fit:
|
||||
|
||||
- weak
|
||||
|
||||
Biggest gaps:
|
||||
|
||||
- lots
|
||||
- expiry
|
||||
- FEFO
|
||||
- recall traceability
|
||||
- yield and quality controls
|
||||
|
||||
### Discrete / Job Shop
|
||||
|
||||
Current fit:
|
||||
|
||||
- moderate foundation
|
||||
|
||||
Strengths:
|
||||
|
||||
- work orders
|
||||
- stations
|
||||
- operation templates
|
||||
- issue/completion posting
|
||||
|
||||
Biggest gaps:
|
||||
|
||||
- finite scheduling
|
||||
- labor tracking
|
||||
- job costing
|
||||
- tooling
|
||||
- dispatch UI
|
||||
|
||||
### Multi-Site Enterprise
|
||||
|
||||
Current fit:
|
||||
|
||||
- weak to moderate
|
||||
|
||||
Strengths:
|
||||
|
||||
- warehouses and locations exist
|
||||
|
||||
Biggest gaps:
|
||||
|
||||
- plant-aware planning
|
||||
- inter-site planning logic
|
||||
- enterprise integrations
|
||||
- supplier/3PL/EDI ecosystem support
|
||||
|
||||
## Bottom Line
|
||||
|
||||
CODEXIUM is already credible as a modular manufacturing operations foundation and has unusually strong project, planning, and cross-module linkage for its current maturity level.
|
||||
|
||||
Where it is strongest today:
|
||||
|
||||
- small to mid-sized discrete manufacturers
|
||||
- make-to-order environments
|
||||
- project-linked manufacturing operations
|
||||
- organizations that value deployment simplicity and integrated workflows over enterprise breadth
|
||||
|
||||
Where it is not yet market-complete:
|
||||
|
||||
- advanced scheduling
|
||||
- regulated traceability
|
||||
- shop-floor labor/cost/quality depth
|
||||
- engineering control
|
||||
- enterprise integrations
|
||||
|
||||
Best strategic path:
|
||||
|
||||
1. Double down on current strengths by deepening planning, projects, and manufacturing first.
|
||||
2. Add traceability and execution discipline next to broaden manufacturing fit.
|
||||
3. Open the integration layer after the core operational model is more mature.
|
||||
|
||||
That sequence gives CODEXIUM the clearest path from strong foundation MRP to differentiated manufacturing platform.
|
||||
135
README.md
135
README.md
@@ -1,35 +1,53 @@
|
||||
# MRP Codex
|
||||
# CODEXIUM
|
||||
|
||||
Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment.
|
||||
|
||||
## Documentation Maintenance
|
||||
|
||||
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated for shipped features, workflow changes, and notable operational updates.
|
||||
- Keep [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) aligned when changes affect their scope.
|
||||
- Keep [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) aligned when changes affect their scope.
|
||||
|
||||
Current foundation scope includes:
|
||||
|
||||
- authentication and RBAC
|
||||
- company branding and theme settings
|
||||
- company branding and theme settings, including startup brand-theme hydration across refresh
|
||||
- CRM customers and vendors with create/edit/detail workflows
|
||||
- CRM search, filtering, status tagging, and reseller hierarchy
|
||||
- CRM contact history, account contacts, and shared attachments
|
||||
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
|
||||
- inventory transfers, reservations, and available-stock visibility
|
||||
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
|
||||
- staged thumbnail image attachment on inventory item create/edit workflows, with detail-page thumbnail display
|
||||
- sales quotes and sales orders with searchable customer and SKU entry
|
||||
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders
|
||||
- sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
|
||||
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
|
||||
- purchase-order revision history and revision comparison across commercial and receipt changes
|
||||
- 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
|
||||
- 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
|
||||
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
|
||||
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
|
||||
- planning gantt timelines with live project and manufacturing schedule data
|
||||
- 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, 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, 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 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
|
||||
- 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
|
||||
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
|
||||
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
|
||||
- admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, review filtering, and unusual-access cues
|
||||
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
|
||||
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
|
||||
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
||||
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics
|
||||
- support-log viewing, filtering, retention cleanup, and richer support-debug export helpers in admin diagnostics
|
||||
- route-level code-splitting and vendor chunking for lighter initial client loads
|
||||
- file storage and PDF rendering
|
||||
|
||||
## Product Map
|
||||
|
||||
Shipped phase history now lives in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md). [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) now tracks remaining work only.
|
||||
|
||||
Current completed foundation areas:
|
||||
|
||||
- dashboard foundation
|
||||
@@ -40,19 +58,19 @@ Current completed foundation areas:
|
||||
- projects foundation
|
||||
- manufacturing foundation
|
||||
- planning foundation
|
||||
- audit and diagnostics foundation
|
||||
- user and role administration foundation
|
||||
- branding, attachments, auth/RBAC, and PDF infrastructure
|
||||
|
||||
Near-term priorities:
|
||||
|
||||
1. Broader audit-trail coverage and operational diagnostics
|
||||
2. Code-splitting and bundle-size reduction
|
||||
1. Deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
|
||||
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||
|
||||
Revisit / deferred items:
|
||||
|
||||
- local Windows Prisma migration reliability
|
||||
- frontend code-splitting and bundle-size reduction
|
||||
- inventory transfers, reservations, and deeper stock controls
|
||||
- deeper audit-trail coverage
|
||||
- deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
|
||||
|
||||
Dashboard direction:
|
||||
|
||||
@@ -63,7 +81,7 @@ Dashboard direction:
|
||||
- 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
|
||||
- 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
|
||||
|
||||
Navigation direction:
|
||||
@@ -71,28 +89,50 @@ Navigation direction:
|
||||
- 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
|
||||
- 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 are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, notes, commercial document links, shipment links, attachments, and dashboard visibility.
|
||||
Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery/purchasing visibility, readiness-risk scoring, a cost snapshot from linked purchasing and manufacturing data, direct launch paths into prefilled purchasing/manufacturing follow-through, an activity timeline across linked execution records, notes, commercial document links, shipment links, attachments, and dashboard visibility.
|
||||
|
||||
Current interactions:
|
||||
|
||||
- CRM: each project should link to a customer account and relevant contacts
|
||||
- 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
|
||||
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets
|
||||
- Detail/List UX: projects now surface milestone progress and linked execution rollups
|
||||
|
||||
Next expansion areas:
|
||||
|
||||
- Inventory: projects should reference item/BOM scope and later expose shortages or allocations
|
||||
- Purchasing: project material demand should be visible to purchasing and receiving workflows
|
||||
- Manufacturing: work orders should link back to projects without turning projects into the manufacturing module
|
||||
- Planning: project milestones and execution dates should feed gantt scheduling and dependency views
|
||||
- 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 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 workbench scheduling, dependency views, and richer planner drilldowns
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -104,16 +144,16 @@ Next expansion areas:
|
||||
|
||||
- Purchasing: shortages and buyout demand should surface from manufacturing execution
|
||||
- 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 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:
|
||||
|
||||
- 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
|
||||
|
||||
Next expansion areas:
|
||||
@@ -163,7 +203,11 @@ Command-line build notes:
|
||||
docker build --build-arg NODE_VERSION=22 -t mrp-codex .
|
||||
```
|
||||
|
||||
The container startup script runs `npx prisma migrate deploy` automatically before launching the server.
|
||||
The container startup script runs the server workspace Prisma binary directly:
|
||||
|
||||
```bash
|
||||
/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma
|
||||
```
|
||||
|
||||
This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM and inventory migrations on Windows.
|
||||
|
||||
@@ -211,8 +255,7 @@ The current inventory foundation supports:
|
||||
- item on-hand quantity, stock-by-location balances, and recent stock history
|
||||
- reserved and available quantity visibility by location
|
||||
- item-level file attachments for drawings and support documents
|
||||
- seeded sample inventory items and a starter assembly BOM during bootstrap
|
||||
- seeded sample warehouse and stock locations during bootstrap
|
||||
- fresh bootstrap starts inventory and warehouse data empty so first-run environments do not include demo operational records
|
||||
|
||||
QOL direction:
|
||||
|
||||
@@ -275,6 +318,9 @@ The current shipping foundation supports:
|
||||
- shipment list, detail, create, and edit flows
|
||||
- searchable sales-order lookup instead of a static order dropdown
|
||||
- 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
|
||||
- shipment quick status actions from the shipment detail page
|
||||
- related-shipment visibility from the sales-order detail page
|
||||
@@ -313,7 +359,7 @@ Logo uploads are stored through the authenticated file pipeline and are rendered
|
||||
- Apply committed migrations in production: `npm run prisma:deploy`
|
||||
- If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth.
|
||||
|
||||
As of March 14, 2026, the latest committed domain migrations include:
|
||||
As of March 15, 2026, the latest committed domain migrations include:
|
||||
|
||||
- CRM status and list filters
|
||||
- CRM contact-history timeline
|
||||
@@ -333,18 +379,49 @@ As of March 14, 2026, the latest committed domain migrations include:
|
||||
- shipping foundation
|
||||
- projects foundation
|
||||
- manufacturing foundation
|
||||
- planning foundation
|
||||
- manufacturing stations and operation templates
|
||||
- inventory transfers and reservations
|
||||
- audit trail and diagnostics foundation
|
||||
- auth-session visibility and revocation
|
||||
- session review filters, unusual-access cues, and startup pruning of stale expired/revoked session records
|
||||
- supply pegging and preferred-vendor sourcing
|
||||
|
||||
Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment.
|
||||
|
||||
## Audit And Diagnostics
|
||||
|
||||
The current admin operations slice supports:
|
||||
|
||||
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
|
||||
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
|
||||
- a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations
|
||||
- prefilled work-order and purchase-order draft launch paths from sales-order demand-planning recommendations
|
||||
- shared shortage/readiness rollups across planning, project, purchasing, dashboard, and manufacturing views
|
||||
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, role-permission administration, and session visibility/revocation
|
||||
- session review filters and flagged cues for stale activity, multi-session overlap, and multi-IP access patterns
|
||||
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
|
||||
- startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
|
||||
- startup now prunes stale expired or revoked auth-session records before serving requests
|
||||
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff
|
||||
- support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review
|
||||
- support logs now support admin-side filtering by severity, source, search text, and retention window, and exports include summary metadata
|
||||
- backup verification items and restore-drill expected outcomes now live in the admin runbook surface
|
||||
- operator-facing review of recent high-impact changes without direct database access
|
||||
|
||||
Current follow-up direction:
|
||||
|
||||
- revision comparison UX for changed sales and purchasing documents
|
||||
- deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
|
||||
|
||||
## UI Notes
|
||||
|
||||
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
|
||||
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
|
||||
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, finance, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
|
||||
- The active module screens now follow a tighter density baseline for forms, tables, and detail cards.
|
||||
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data.
|
||||
- The client build still emits a Vite chunk-size warning because the app has not been code-split yet.
|
||||
- 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`.
|
||||
|
||||
## PDF Generation
|
||||
|
||||
Puppeteer is used by the backend to render HTML templates into professional PDFs. The current PDF surface includes the branded company-profile preview, sales quotes, sales orders, purchase orders, shipment packing slips, shipping labels, and bills of lading. The Docker image includes Chromium runtime dependencies required for headless execution.
|
||||
|
||||
|
||||
268
ROADMAP.md
268
ROADMAP.md
@@ -3,218 +3,106 @@
|
||||
## Documentation maintenance
|
||||
|
||||
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated alongside roadmap-driving feature completion, priority shifts, and notable delivery milestones.
|
||||
- Keep [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md) updated when roadmap items move from planned to delivered.
|
||||
- When roadmap changes affect implementation guidance or deployment expectations, update the companion docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) in the same change set.
|
||||
|
||||
## Product direction
|
||||
|
||||
MRP Codex is being built as a streamlined, modular manufacturing resource planning platform with strong branding controls, fast operational workflows, and a single-container deployment model that is simple to back up and upgrade.
|
||||
CODEXIUM is being built as a streamlined, modular manufacturing resource planning platform with strong branding controls, fast operational workflows, and a single-container deployment model that is simple to back up and upgrade.
|
||||
|
||||
## Current status
|
||||
This file tracks work that still needs to be completed. Shipped phase history and completed slices now live in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md).
|
||||
|
||||
### Completed: Foundation release
|
||||
## Near-term priority order
|
||||
|
||||
- Monorepo-style workspace with `client`, `server`, and `shared`
|
||||
- React + Vite + Tailwind frontend shell
|
||||
- Express + TypeScript backend shell
|
||||
- Prisma + SQLite schema foundation with committed initial migration
|
||||
- Local authentication with JWT-based session flow
|
||||
- RBAC permission model and protected routes
|
||||
- Central Company Settings with runtime branding controls
|
||||
- Light and dark mode theme system
|
||||
- Local file attachment storage under `/app/data/uploads`
|
||||
- Puppeteer PDF service foundation with branded company-profile preview
|
||||
- CRM reference entities for customers and vendors
|
||||
- CRM customer and vendor create/edit/detail workflows
|
||||
- CRM search, filters, and persisted status tagging
|
||||
- CRM contact-history timeline with authored notes, calls, emails, and meetings
|
||||
- CRM shared file attachments on customer and vendor records, including delete support
|
||||
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support
|
||||
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
|
||||
- Inventory item master, BOM, warehouse, and stock-location foundation
|
||||
- Inventory transactions, on-hand tracking, and item attachments
|
||||
- Inventory transfers, reservations, available-stock visibility, and work-order-driven material reservation automation
|
||||
- Sales quotes and sales orders with commercial totals logic
|
||||
- Purchase orders with vendor lookup, item lines, totals, and quick status actions
|
||||
- Purchase-order line selection restricted to inventory items flagged as purchasable
|
||||
- Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking
|
||||
- Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline
|
||||
- Shipping shipment records linked to sales orders
|
||||
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
|
||||
- Logistics attachments directly on shipment records
|
||||
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
|
||||
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
|
||||
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||
- SKU-searchable BOM component selection for inventory-scale datasets
|
||||
- Theme persistence fixes and denser responsive workspace layouts
|
||||
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||
- Live planning gantt timelines driven by project and manufacturing data
|
||||
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||
- Docker image validated locally with successful app startup and login flow
|
||||
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
|
||||
1. Manufacturing costing and execution depth, including scrap/rework/yield tracking and variance visibility
|
||||
2. Finance expansion across AP disbursements, invoice matching, vendor payments, and project-level P&L
|
||||
3. Workbench finite-capacity intelligence, including conflict handling, queue-slot guidance, and auto-rebalance recommendations
|
||||
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
|
||||
|
||||
### Current known gaps in the foundation
|
||||
## Active roadmap
|
||||
|
||||
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
|
||||
- The frontend bundle is functional but should be code-split later, especially around the gantt module
|
||||
- CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
|
||||
- The current sales/purchasing/shipping foundation now includes sales approvals and revision history, but still needs vendor exception handling, deeper carrier integration, and richer document comparison tooling
|
||||
- The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added
|
||||
- The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins
|
||||
- The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins
|
||||
### Platform and operational docs
|
||||
|
||||
## Dashboard Plan
|
||||
- Keep the Windows Prisma migration workflow clearer and less fragile for local contributors
|
||||
- Continue tightening backup, restore, and support-runbook guidance as operations maturity grows
|
||||
- Preserve the single-container deployment path while improving diagnostics and supportability
|
||||
|
||||
- Keep `Dashboard` as the primary landing surface for operators
|
||||
- Expand it by modular panels rather than redesigning it for each new feature phase
|
||||
- Prefer metric cards, exception queues, action shortcuts, and status summaries over static descriptive content
|
||||
- Add future widgets for purchasing, shipping exceptions, inventory shortages, planning readiness, and audit/system health
|
||||
- Continue expanding the new project widgets into milestone, blockage, and shipment-readiness views instead of creating a separate landing area
|
||||
- Continue expanding the new manufacturing widgets into shortage, routing, and bottleneck views instead of creating a separate landing area
|
||||
- Treat dashboard modules as upgradeable blocks that can be reordered or expanded without disturbing the shell
|
||||
### Dashboard
|
||||
|
||||
## Planned feature phases
|
||||
- Expand `Dashboard` by modular panels rather than redesigning it into a different shell
|
||||
- Add richer KPI widgets, alerts, recent-activity queues, and exception reporting
|
||||
- Add deeper project, manufacturing, purchasing, finance, shipping, and audit/system-health widgets
|
||||
|
||||
### Phase 1: CRM and master data hardening
|
||||
### CRM and master data
|
||||
|
||||
- Better seed/bootstrap strategy for non-development environments
|
||||
- Additional CRM account-role depth if later sales/purchasing workflows need it
|
||||
- More derived CRM rollups once quotes, orders, and purchasing documents exist
|
||||
|
||||
QOL subfeatures:
|
||||
|
||||
- More derived CRM rollups once downstream quote/order/purchasing/shipping data grows further
|
||||
- Saved CRM filters and quick views
|
||||
- Better hierarchy navigation between reseller parents and child accounts
|
||||
- One-click contact actions for email and phone workflows
|
||||
- Duplicate-account detection and merge workflow
|
||||
- Cleaner attachment previews and richer record timelines
|
||||
- More compact table controls for heavy CRM data-entry users
|
||||
- CRM document rollups and broader account-role depth that were deferred until downstream modules matured
|
||||
|
||||
### Phase 2: Inventory and manufacturing core
|
||||
### Inventory
|
||||
|
||||
- Item master and SKU structure foundation
|
||||
- Warehouse and stock location foundation
|
||||
- Inventory transactions and on-hand tracking foundation
|
||||
- Bills of materials and custom assemblies foundation
|
||||
- File attachments for BOM drawings and manufacturing support docs foundation
|
||||
|
||||
QOL subfeatures:
|
||||
|
||||
- Item master enrichment: categories, alternate part numbers, revisions, preferred vendor data, and reorder settings
|
||||
- Stock transfers between warehouses and locations
|
||||
- Reservation and allocation visibility against demand
|
||||
- Faster SKU search and keyboard-heavy item/BOM entry flows refinement
|
||||
- Better warehouse dashboards for on-hand, shortages, and recent movement
|
||||
- Item master enrichment: categories, alternate part numbers, revisions, reorder settings, and broader sourcing metadata
|
||||
- Faster keyboard-heavy item/BOM entry refinement beyond the current searchable pickers
|
||||
- Better warehouse dashboards for on-hand, shortages, reservations, and recent movement
|
||||
- BOM revision support and clearer where-used visibility
|
||||
- Bulk item import/export and mass-update utilities
|
||||
|
||||
### Phase 3: Sales and purchasing documents
|
||||
|
||||
- Quotes, sales orders, and purchase orders
|
||||
- Reusable line-item and totals model
|
||||
- Purchase receiving flow tied to purchase-order lines and inventory receipts foundation
|
||||
- Document states, approvals, and revision history
|
||||
- Branded PDF templates rendered through Puppeteer
|
||||
- Attachments for vendor invoices and supporting documents
|
||||
|
||||
Foundation slice shipped:
|
||||
|
||||
- Sales approval stamps and automatic revision history on quotes and sales orders
|
||||
- Purchase-order supporting documents through the shared attachment pipeline
|
||||
- Vendor-detail purchasing visibility for recent purchase-order activity
|
||||
|
||||
QOL subfeatures:
|
||||
### Sales and purchasing
|
||||
|
||||
- Vendor exception handling for acknowledgements, invoice matching, receipt discrepancies, and related inbound follow-up
|
||||
- Deeper carrier/commercial defaults where they improve order-entry speed
|
||||
- Line duplication, drag ordering, and keyboard-first line editing
|
||||
- Saved customer defaults for tax, freight, and commercial terms
|
||||
- Inline stock visibility while building quotes and orders
|
||||
- Restrict purchase-order item entry to purchasable inventory only
|
||||
- Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions
|
||||
- Better totals breakdown visibility on list pages and detail pages
|
||||
- Revision comparison view for changed customer-facing documents
|
||||
- Faster document cloning and quote-to-order style conversions across document types
|
||||
|
||||
### Phase 4: Shipping and logistics
|
||||
### Finance
|
||||
|
||||
- Shipment records linked to sales orders
|
||||
- Bills of lading, packing slips, and shipping BOM PDFs
|
||||
- Carrier, package, and tracking data
|
||||
- Outbound shipment status workflow
|
||||
- Scanned logistics-document attachment handling
|
||||
- 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
|
||||
|
||||
QOL subfeatures:
|
||||
### Shipping and logistics
|
||||
|
||||
- Printer-friendly reprint and history actions for logistics documents
|
||||
- Partial shipment workflow and split-shipment visibility
|
||||
- Better tracking-link UX and carrier-specific shortcuts
|
||||
- Packing verification and ship-confirm checkpoints
|
||||
- Shipment search by order, tracking, customer, and carrier from one screen
|
||||
- Reprint and history actions for generated logistics PDFs
|
||||
- Printer-friendly reprint/history actions for logistics documents
|
||||
|
||||
### Phase 5: Projects and program management
|
||||
|
||||
Foundation slice shipped:
|
||||
|
||||
- Project records with customer linkage, status, owner, priority, due dates, and notes
|
||||
- Project-to-quote, sales-order, and shipment linkage for delivery context
|
||||
- Project attachments through the shared file pipeline
|
||||
- Project list/detail/create/edit flows and dashboard visibility
|
||||
### Projects and program management
|
||||
|
||||
- Project document hub for drawings, support files, correspondence, and revision references
|
||||
- Milestones, checkpoints, and non-manufacturing work packages for long-running execution tracking
|
||||
- Project-level commercial, material, schedule, and delivery rollups
|
||||
- Non-manufacturing work packages for long-running execution tracking
|
||||
- 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
|
||||
|
||||
Module interactions:
|
||||
|
||||
- CRM: projects link to customer accounts, reseller-owned end customers, contacts, and account notes
|
||||
- Sales: quotes and sales orders can spawn or attach to projects; project status should reflect commercial state where relevant
|
||||
- Inventory: projects reference item/BOM scope, expose shortage/reservation pressure, and later roll up material readiness
|
||||
- Purchasing: projects surface buyout demand and vendor receipts tied to project material needs
|
||||
- Shipping: shipments should be visible from the project record when a project drives deliverables
|
||||
- Dashboard: projects add live widgets for active programs, overdue milestones, shortages, and blocked delivery
|
||||
- Manufacturing: manufacturing orders and shop execution should link back to projects, but remain their own subsystem
|
||||
- Gantt/planning: project milestones and execution dates should feed planning views without collapsing projects into scheduling alone
|
||||
|
||||
QOL subfeatures:
|
||||
|
||||
- Project templates for repeatable build types
|
||||
- Project-specific attachment bundles and revision snapshots
|
||||
- One-screen project cockpit with commercial, material, schedule, and shipping summary
|
||||
- One-screen project cockpit with deeper cost, material, schedule, shipping, finance, and action-oriented summary workflows
|
||||
- Better cross-links between project, customer, order, shipment, and inventory records
|
||||
- Project filtering by customer, owner, status, due date, and risk
|
||||
- Project activity timeline and audit-friendly milestone history
|
||||
|
||||
### Phase 6: Manufacturing execution
|
||||
### Manufacturing execution
|
||||
|
||||
Foundation slice shipped:
|
||||
|
||||
- Work orders tied to manufactured or assembly items, with optional project linkage
|
||||
- BOM-based material requirement visibility from the work-order record
|
||||
- Material issue posting that creates real inventory issue transactions
|
||||
- Production completion posting that creates finished-goods receipt transactions
|
||||
- Work-order list/detail/create/edit flows, attachments, and dashboard visibility
|
||||
|
||||
- Work orders tied to projects, sales demand, or internal build demand
|
||||
- Routing/work-center structure for manufacturing steps and handoffs
|
||||
- Material issue, consumption, completion, and WIP tracking
|
||||
- Labor and machine-time capture for production execution
|
||||
- Manufacturing status workflow from release through completion
|
||||
- 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
|
||||
- Material consumption depth, WIP tracking, and execution traceability
|
||||
- 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
|
||||
|
||||
Module interactions:
|
||||
|
||||
- Projects: manufacturing orders can be attached to projects, but projects remain the higher-level long-running record
|
||||
- Inventory: manufacturing consumes components and produces finished/semi-finished stock
|
||||
- Purchasing: shortages and buyout demand should be visible from manufacturing execution
|
||||
- Shipping: completed manufacturing should feed shipment readiness, but shipping remains separate
|
||||
- Dashboard: manufacturing adds live queues for open jobs, blocked work, overdue orders, and completion throughput
|
||||
- Planning: manufacturing orders and routings become a major input into capacity and gantt scheduling
|
||||
|
||||
QOL subfeatures:
|
||||
|
||||
- Traveler/job packet output
|
||||
- Partial completions and split-order execution visibility
|
||||
- Better shortage and substitute-part handling
|
||||
@@ -222,55 +110,34 @@ QOL subfeatures:
|
||||
- Rework / hold / scrap tracking
|
||||
- Work-center dashboards and operator-focused queues
|
||||
|
||||
### Phase 7: Planning and scheduling
|
||||
### Planning and scheduling
|
||||
|
||||
Foundation slice shipped:
|
||||
|
||||
- Live gantt schedule backed by active projects and open manufacturing work orders
|
||||
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
|
||||
- Planning exception queue for overdue or at-risk project/manufacturing schedule items
|
||||
|
||||
- Live project-backed SVAR gantt timelines
|
||||
- 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
|
||||
- Manufacturing calendar views and bottleneck visibility
|
||||
- Labor and machine scheduling support
|
||||
- Theme-compliant gantt customization for light/dark mode
|
||||
|
||||
QOL subfeatures:
|
||||
|
||||
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
|
||||
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
|
||||
- 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
|
||||
- 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
|
||||
- Faster filtering by project, customer, work center, and status
|
||||
|
||||
### Phase 8: Security, audit, and operations maturity
|
||||
### Demand planning and supply generation
|
||||
|
||||
- Expanded role management UI
|
||||
- Permission assignment administration
|
||||
- Audit trail coverage across critical records
|
||||
- Backup/restore workflow documentation and scripts
|
||||
- Health checks, startup diagnostics, and production readiness cleanup
|
||||
- Deeper planner drilldowns from demand source to buy/build action without re-keying data
|
||||
- Better shortage and substitute-part guidance during planning review
|
||||
- Saved planning views by customer, project, item family, and shortage state
|
||||
- Time-phased supply recommendations with vendor lead times and build timing
|
||||
|
||||
QOL subfeatures:
|
||||
### Security, audit, and operations maturity
|
||||
|
||||
- Admin diagnostics screen for permissions, migrations, storage, and PDF health
|
||||
- Safer destructive-action confirmations and recovery messaging
|
||||
- Better user/session visibility for operational admins
|
||||
- Admin diagnostics depth for permissions, migrations, storage, and PDF health
|
||||
- Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
||||
- More explicit environment validation on startup
|
||||
- Log-view and export helpers for support/debugging
|
||||
- Backup verification checklist and restore drill guidance
|
||||
|
||||
## Revisit / Deferred Items
|
||||
|
||||
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
|
||||
- Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
|
||||
- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up
|
||||
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist
|
||||
- Audit-trail depth is still thin outside the current record/update flows
|
||||
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
|
||||
- Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred
|
||||
- Backup verification and restore-drill guidance should keep expanding as the system grows
|
||||
|
||||
## Cross-cutting improvements
|
||||
|
||||
@@ -281,7 +148,8 @@ QOL subfeatures:
|
||||
- Consistent document-template system shared by sales, purchasing, and shipping
|
||||
- Clear upgrade path for future module additions without refactoring the app shell
|
||||
|
||||
## Near-term priority order
|
||||
## Revisit / Deferred Items
|
||||
|
||||
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
|
||||
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
|
||||
|
||||
1. Broader audit-trail coverage and operational diagnostics
|
||||
2. Code-splitting and bundle-size reduction
|
||||
|
||||
141
SHIPPED.md
Normal file
141
SHIPPED.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Shipped
|
||||
|
||||
This file tracks roadmap phases, slices, and major foundations that have already shipped. Remaining work lives in [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md).
|
||||
|
||||
## Foundation release
|
||||
|
||||
- Monorepo-style workspace with `client`, `server`, and `shared`
|
||||
- React + Vite + Tailwind frontend shell
|
||||
- Express + TypeScript backend shell
|
||||
- Prisma + SQLite schema foundation with committed initial migration
|
||||
- Local authentication with JWT-based session flow plus persisted session visibility and revocation
|
||||
- RBAC permission model and protected routes
|
||||
- Central Company Settings with runtime branding controls
|
||||
- Light and dark mode theme system
|
||||
- Local file attachment storage under `/app/data/uploads`
|
||||
- Puppeteer PDF service foundation with branded company-profile preview
|
||||
- CRM reference entities for customers and vendors
|
||||
- CRM customer and vendor create/edit/detail workflows
|
||||
- CRM search, filters, and persisted status tagging
|
||||
- CRM contact-history timeline with authored notes, calls, emails, and meetings
|
||||
- CRM shared file attachments on customer and vendor records, including delete support
|
||||
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support
|
||||
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
|
||||
- Inventory item master, BOM, warehouse, and stock-location foundation
|
||||
- Inventory transactions, on-hand tracking, and item attachments
|
||||
- Inventory transfers, reservations, available-stock visibility, and work-order-driven material reservation automation
|
||||
- Sales quotes and sales orders with commercial totals logic
|
||||
- Purchase orders with vendor lookup, item lines, totals, and quick status actions
|
||||
- Purchase-order line selection restricted to inventory items flagged as purchasable
|
||||
- Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking
|
||||
- Finance module with sales-order-linked customer payments, live spend/margin rollups across linked purchase orders and manufacturing, finance costing assumptions, and CapEx tracking for equipment, tooling, and consumables
|
||||
- Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline
|
||||
- Shipping shipment records linked to sales orders
|
||||
- Inventory-backed shipment picking with stock issue posting from warehouse locations and shipment-side pick history
|
||||
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
|
||||
- Logistics attachments directly on shipment records
|
||||
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||
- Reverse project linkage visibility on quote and sales-order detail pages, plus project-context carry-through into generated work orders and purchase orders with sales-order-driven backfill for existing records
|
||||
- Project milestones, inline milestone quick-status actions, and project-side milestone/work-order rollups
|
||||
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||
- Manufacturing foundation with work orders, project linkage, operation execution controls, operator assignment, timer-based and manual labor posting, required hold reasons for `On Hold` status changes, material issue posting, completion posting, and work-order attachments
|
||||
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
||||
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
|
||||
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
|
||||
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
|
||||
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
|
||||
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
|
||||
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
|
||||
- Backup/restore guidance and exportable support bundles surfaced through the admin diagnostics workflow
|
||||
- Backup verification checklist and restore-drill runbook surfaced through the admin diagnostics workflow
|
||||
- Support-log viewing for startup warnings, HTTP failures, and server errors surfaced through the admin diagnostics workflow
|
||||
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
|
||||
- SKU-searchable BOM component selection for inventory-scale datasets
|
||||
- Theme persistence fixes and denser responsive workspace layouts
|
||||
- Startup brand-theme hydration so Company Settings colors and font persist correctly across refresh
|
||||
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||
- Live planning workbench timelines driven by project and manufacturing data
|
||||
- Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions
|
||||
- Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling
|
||||
- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, station-lane drag scheduling, and planned-vs-actual station load visibility
|
||||
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||
- Docker image validated locally with successful app startup and login flow
|
||||
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
|
||||
|
||||
## Shipped roadmap phases
|
||||
|
||||
### Phase 3: Sales and purchasing documents
|
||||
|
||||
- Sales approval stamps and automatic revision history on quotes and sales orders
|
||||
- Purchase-order supporting documents through the shared attachment pipeline
|
||||
- Vendor-detail purchasing visibility for recent purchase-order activity
|
||||
|
||||
### Phase 5: Projects and program management
|
||||
|
||||
- Project records with customer linkage, status, owner, priority, due dates, milestones, and notes
|
||||
- Project milestone status tracking and project-side milestone/work-order rollups
|
||||
- Project-to-quote, sales-order, and shipment linkage for delivery context
|
||||
- Quote-to-sales-order project carry-through plus reverse-linked project visibility from the sales workflow
|
||||
- Project attachments through the shared file pipeline
|
||||
- Project list/detail/create/edit flows and dashboard visibility
|
||||
|
||||
### Phase 6: Manufacturing execution
|
||||
|
||||
- Work orders tied to manufactured or assembly items, with optional project linkage
|
||||
- BOM-based material requirement visibility from the work-order record
|
||||
- Material issue posting that creates real inventory issue transactions
|
||||
- Production completion posting that creates finished-goods receipt transactions
|
||||
- Work-order list/detail/create/edit flows, attachments, and dashboard visibility
|
||||
|
||||
### Phase 7: Planning and scheduling
|
||||
|
||||
- Live workbench schedule backed by active projects and open manufacturing work orders
|
||||
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
|
||||
- Planning exception queue for overdue or at-risk project/manufacturing schedule items
|
||||
- Station load summaries, release-ready visibility, and inline workbench follow-through actions for release/build/buy dispatch
|
||||
|
||||
### Phase 8: Demand planning and supply generation
|
||||
|
||||
- Sales-order demand planning from approved or active demand records
|
||||
- Multi-level BOM explosion from sales-order lines through manufactured and assembly children
|
||||
- Netting against available stock, active reservations, open work orders, and open purchase orders
|
||||
- Build and buy recommendations surfaced directly from the sales-order workflow
|
||||
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
|
||||
- Generated work orders and purchase orders now auto-carry linked project context when demand traces back to a project-linked sales order
|
||||
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
|
||||
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
|
||||
- Pegged work-order and purchase-order supply links back to originating sales demand
|
||||
- Planning recommendations now reduce against already-linked draft/open supply to avoid duplicate WO/PO generation
|
||||
|
||||
### Phase 9: Security, audit, and operations maturity
|
||||
|
||||
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
|
||||
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
|
||||
- Expanded role-management UI with account creation, activation, role assignment, and permission administration
|
||||
- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins
|
||||
- Server-side logout and admin session revocation for JWT-backed access
|
||||
- Session review filtering, unusual-access cues, diagnostics rollups, and startup pruning of stale expired/revoked auth sessions
|
||||
- Shared destructive-action confirmation and recovery messaging for admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse/form-editor, and attachment workflows
|
||||
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
|
||||
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
|
||||
- Backup/restore guidance, support-bundle exports, and support-log viewing surfaced through the admin diagnostics workflow
|
||||
|
||||
## Shipped quality-of-life slices
|
||||
|
||||
- Purchase-order item entry restricted to purchasable inventory items only
|
||||
- Inventory transfers between warehouses and locations
|
||||
- Manual and work-order-driven inventory reservations
|
||||
- Reserved and available stock visibility on inventory item detail and stock-by-location views
|
||||
- Searchable operational pickers for customers, vendors, SKUs, BOM components, and other dense record selectors
|
||||
- Route-level lazy loading and vendor chunking for a lighter initial client payload
|
||||
- Persisted auth-session review filtering and admin-side access review cues
|
||||
- Destructive-action confirmation coverage expanded into project customer/document unlinking and form-row removals in sales, purchasing, inventory, and warehouse editors
|
||||
- Support-log filtering, retention cleanup, and richer filtered support-bundle exports in admin diagnostics
|
||||
- Inventory thumbnail staging on item create/edit plus dedicated detail-page thumbnail display
|
||||
- Demand-planning PO draft generation no longer applies mismatched sales-order line links to BOM child buy recommendations
|
||||
- User-management saves now accept blank-password edits for existing users and surface errors in-page
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
- Organize domain modules under `src/modules/<domain>`.
|
||||
- Keep HTTP routers thin; place business logic in services.
|
||||
- Centralize Prisma access, auth middleware, and file storage utilities in `src/lib`.
|
||||
- Centralize Prisma access, auth middleware, persisted session helpers, file storage utilities, startup validation, and support logging in `src/lib`.
|
||||
- Store persistence-related constants under `src/config`.
|
||||
- Serve the built frontend from the API layer in production.
|
||||
|
||||
@@ -52,3 +52,4 @@
|
||||
3. Add permission keys in `shared/src/auth`.
|
||||
4. Add frontend route/module under `client/src/modules/<domain>`.
|
||||
5. Register navigation and route guards through the app shell without refactoring existing modules.
|
||||
|
||||
|
||||
19
UNRAID.md
19
UNRAID.md
@@ -7,7 +7,7 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
This guide explains how to deploy MRP Codex on an Unraid server using the Unraid Docker GUI rather than command-line Docker management.
|
||||
This guide explains how to deploy CODEXIUM on an Unraid server using the Unraid Docker GUI rather than command-line Docker management.
|
||||
|
||||
## What this container expects
|
||||
|
||||
@@ -16,7 +16,7 @@ This guide explains how to deploy MRP Codex on an Unraid server using the Unraid
|
||||
- Environment variables for the server port, JWT secret, and SQLite path
|
||||
- Automatic Prisma migration execution during container startup
|
||||
|
||||
MRP Codex stores both the SQLite database and uploaded files inside the same persistent container path:
|
||||
CODEXIUM stores both the SQLite database and uploaded files inside the same persistent container path:
|
||||
|
||||
- database: `/app/data/prisma/app.db`
|
||||
- uploads: `/app/data/uploads`
|
||||
@@ -25,15 +25,15 @@ MRP Codex stores both the SQLite database and uploaded files inside the same per
|
||||
|
||||
- Build and push the image to a registry your Unraid server can access, or import it manually if you are self-hosting images
|
||||
- Choose an Unraid share for persistent app data, for example:
|
||||
- `/mnt/user/appdata/mrp-codex`
|
||||
- `/mnt/user/appdata/codexium`
|
||||
- Choose the port you want to expose in Unraid, for example:
|
||||
- host port `3000`
|
||||
- container port `3000`
|
||||
|
||||
## Recommended Unraid paths
|
||||
|
||||
- App data share: `/mnt/user/appdata/mrp-codex`
|
||||
- Optional backup target: `/mnt/user/backups/mrp-codex`
|
||||
- App data share: `/mnt/user/appdata/codexium`
|
||||
- Optional backup target: `/mnt/user/backups/codexium`
|
||||
|
||||
Keep the entire app data folder together so backups capture both the SQLite database and file attachments.
|
||||
|
||||
@@ -106,7 +106,7 @@ If you do not set them, the defaults from the app bootstrapping logic are used.
|
||||
On first container start, the entrypoint will:
|
||||
|
||||
1. Ensure `/app/data/prisma` and `/app/data/uploads` exist
|
||||
2. Run `npx prisma migrate deploy`
|
||||
2. Run `/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma`
|
||||
3. Start the Node.js server
|
||||
|
||||
The frontend is served by the same container as the API, so there is only one exposed web port.
|
||||
@@ -128,9 +128,9 @@ When you publish a new image:
|
||||
2. Apply the update from the Unraid GUI
|
||||
3. Start the container
|
||||
|
||||
Because MRP Codex runs `prisma migrate deploy` during startup, committed migrations are applied automatically before the app launches.
|
||||
Because CODEXIUM runs `prisma migrate deploy` during startup, committed migrations are applied automatically before the app launches.
|
||||
|
||||
This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, and manufacturing work orders. Let the container complete startup migrations before testing new screens.
|
||||
This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, manufacturing work orders, audit tooling, and persisted auth sessions. Let the container complete startup migrations before testing new screens.
|
||||
|
||||
## Backup guidance
|
||||
|
||||
@@ -148,7 +148,7 @@ For consistent backups, stop the container before copying the appdata directory.
|
||||
|
||||
## Reverse proxy notes
|
||||
|
||||
If you place MRP Codex behind Nginx Proxy Manager, Traefik, or another reverse proxy:
|
||||
If you place CODEXIUM behind Nginx Proxy Manager, Traefik, or another reverse proxy:
|
||||
|
||||
- keep the Unraid container on `bridge` or your preferred proxy-compatible network
|
||||
- point the proxy at the Unraid host IP and chosen host port
|
||||
@@ -192,3 +192,4 @@ Set `CLIENT_ORIGIN` to the exact URL used by the browser, including protocol and
|
||||
- Env: `DATABASE_URL=file:../../data/prisma/app.db`
|
||||
- Env: `PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium`
|
||||
- Env: `CLIENT_ORIGIN=http://YOUR-UNRAID-IP:3000`
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isReady: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -48,13 +48,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(result.user);
|
||||
window.localStorage.setItem(tokenKey, result.token);
|
||||
},
|
||||
logout() {
|
||||
async logout() {
|
||||
if (token) {
|
||||
try {
|
||||
await api.logout(token);
|
||||
} catch {
|
||||
// Clearing local auth state still signs the user out on the client.
|
||||
}
|
||||
}
|
||||
window.localStorage.removeItem(tokenKey);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
},
|
||||
}),
|
||||
[isReady, token, user]
|
||||
[token, user, isReady]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
@@ -67,4 +74,3 @@ export function useAuth() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@ const links = [
|
||||
{ to: "/sales/quotes", label: "Quotes", icon: <QuoteIcon /> },
|
||||
{ to: "/sales/orders", label: "Sales Orders", icon: <SalesOrderIcon /> },
|
||||
{ to: "/purchasing/orders", label: "Purchase Orders", icon: <PurchaseOrderIcon /> },
|
||||
{ to: "/finance", label: "Finance", icon: <FinanceIcon /> },
|
||||
{ to: "/shipping/shipments", label: "Shipments", icon: <ShipmentIcon /> },
|
||||
{ to: "/projects", label: "Projects", icon: <ProjectsIcon /> },
|
||||
{ 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 }) {
|
||||
@@ -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 (
|
||||
<NavIcon>
|
||||
<path d="M4 6h5" />
|
||||
@@ -187,21 +200,19 @@ export function AppShell() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-5 xl:px-6 2xl:px-8">
|
||||
<div className="mx-auto flex w-full max-w-[1760px] gap-3 2xl:gap-4">
|
||||
<aside className="hidden w-72 shrink-0 flex-col rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur md:flex 2xl:w-80">
|
||||
<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-2.5 2xl:gap-3">
|
||||
<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 className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">MRP Codex</div>
|
||||
<h1 className="mt-2 text-xl font-extrabold text-text">Manufacturing foundation</h1>
|
||||
<p className="mt-2 text-sm text-muted">Single-tenant platform shell with branding, auth, file storage, and planning foundations.</p>
|
||||
<h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1>
|
||||
</div>
|
||||
<nav className="mt-6 space-y-2">
|
||||
<nav className="mt-4 space-y-1.5">
|
||||
{links.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
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"
|
||||
}`
|
||||
}
|
||||
@@ -211,33 +222,34 @@ export function AppShell() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto rounded-2xl border border-line/70 bg-page/70 p-4">
|
||||
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
|
||||
<p className="text-xs text-muted">{user?.email}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<div className="mt-auto space-y-2.5">
|
||||
<div className="rounded-[16px] border border-line/70 bg-page/70 p-2.5">
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">Theme</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<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-xs text-muted">{user?.email}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
className="mt-3 rounded-xl bg-text px-3 py-2 text-sm font-semibold text-page"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between rounded-[28px] border border-line/70 bg-surface/90 px-2 py-2 shadow-panel backdrop-blur 2xl:px-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Operations Command</p>
|
||||
<h2 className="text-lg font-bold text-text">Foundation Console</h2>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<nav className="mb-4 flex gap-3 overflow-x-auto rounded-[24px] border border-line/70 bg-surface/85 p-3 shadow-panel backdrop-blur md:hidden">
|
||||
<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) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
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"
|
||||
}`
|
||||
}
|
||||
@@ -247,6 +259,9 @@ export function AppShell() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mb-3 md:hidden">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
145
client/src/components/ConfirmActionDialog.tsx
Normal file
145
client/src/components/ConfirmActionDialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ConfirmActionDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
impact?: string;
|
||||
recovery?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
intent?: "danger" | "primary";
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
extraFieldLabel?: string;
|
||||
extraFieldPlaceholder?: string;
|
||||
extraFieldValue?: string;
|
||||
extraFieldRequired?: boolean;
|
||||
extraFieldMultiline?: boolean;
|
||||
onExtraFieldChange?: (value: string) => void;
|
||||
isConfirming?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmActionDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
impact,
|
||||
recovery,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
intent = "danger",
|
||||
confirmationLabel,
|
||||
confirmationValue,
|
||||
extraFieldLabel,
|
||||
extraFieldPlaceholder,
|
||||
extraFieldValue = "",
|
||||
extraFieldRequired = false,
|
||||
extraFieldMultiline = false,
|
||||
onExtraFieldChange,
|
||||
isConfirming = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmActionDialogProps) {
|
||||
const [typedValue, setTypedValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTypedValue("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
||||
const requiresExtraField = Boolean(extraFieldLabel);
|
||||
const isConfirmDisabled =
|
||||
isConfirming ||
|
||||
(requiresTypedConfirmation && typedValue.trim() !== confirmationValue) ||
|
||||
(requiresExtraField && extraFieldRequired && extraFieldValue.trim().length === 0);
|
||||
const confirmButtonClass =
|
||||
intent === "danger"
|
||||
? "bg-red-600 text-white hover:bg-red-700"
|
||||
: "bg-brand text-white hover:brightness-110";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
|
||||
<div className="w-full max-w-xl rounded-[20px] border border-line/70 bg-surface p-5 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
|
||||
{impact ? (
|
||||
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
|
||||
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
|
||||
<span className="mt-1 block">{impact}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
|
||||
<span className="mt-1 block">{recovery}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requiresTypedConfirmation ? (
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">
|
||||
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
|
||||
</span>
|
||||
<input
|
||||
value={typedValue}
|
||||
onChange={(event) => setTypedValue(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{requiresExtraField ? (
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">{extraFieldLabel}</span>
|
||||
{extraFieldMultiline ? (
|
||||
<textarea
|
||||
value={extraFieldValue}
|
||||
onChange={(event) => onExtraFieldChange?.(event.target.value)}
|
||||
placeholder={extraFieldPlaceholder}
|
||||
rows={4}
|
||||
className="w-full rounded-[18px] border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={extraFieldValue}
|
||||
onChange={(event) => onExtraFieldChange?.(event.target.value)}
|
||||
placeholder={extraFieldPlaceholder}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isConfirming}
|
||||
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={isConfirmDisabled}
|
||||
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
|
||||
>
|
||||
{isConfirming ? "Working..." : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type RevisionOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
type ComparisonField = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ComparisonLine = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
quantity: string;
|
||||
unitLabel: string;
|
||||
amountLabel: string;
|
||||
totalLabel?: string;
|
||||
extraLabel?: string;
|
||||
};
|
||||
|
||||
type ComparisonDocument = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
metaFields: ComparisonField[];
|
||||
totalFields: ComparisonField[];
|
||||
notes: string;
|
||||
lines: ComparisonLine[];
|
||||
};
|
||||
|
||||
type DiffRow = {
|
||||
key: string;
|
||||
status: "ADDED" | "REMOVED" | "CHANGED";
|
||||
left?: ComparisonLine;
|
||||
right?: ComparisonLine;
|
||||
};
|
||||
|
||||
function buildLineMap(lines: ComparisonLine[]) {
|
||||
return new Map(lines.map((line) => [line.key, line]));
|
||||
}
|
||||
|
||||
function lineSignature(line?: ComparisonLine) {
|
||||
if (!line) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [line.title, line.subtitle ?? "", line.quantity, line.unitLabel, line.amountLabel, line.totalLabel ?? "", line.extraLabel ?? ""].join("|");
|
||||
}
|
||||
|
||||
function buildDiffRows(left: ComparisonDocument, right: ComparisonDocument): DiffRow[] {
|
||||
const leftLines = buildLineMap(left.lines);
|
||||
const rightLines = buildLineMap(right.lines);
|
||||
const orderedKeys = [...new Set([...left.lines.map((line) => line.key), ...right.lines.map((line) => line.key)])];
|
||||
const rows: DiffRow[] = [];
|
||||
|
||||
for (const key of orderedKeys) {
|
||||
const leftLine = leftLines.get(key);
|
||||
const rightLine = rightLines.get(key);
|
||||
|
||||
if (leftLine && !rightLine) {
|
||||
rows.push({ key, status: "REMOVED", left: leftLine });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!leftLine && rightLine) {
|
||||
rows.push({ key, status: "ADDED", right: rightLine });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineSignature(leftLine) !== lineSignature(rightLine)) {
|
||||
rows.push({ key, status: "CHANGED", left: leftLine, right: rightLine });
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): Array<{ label: string; leftValue: string; rightValue: string }> {
|
||||
const rightByLabel = new Map(right.map((field) => [field.label, field.value]));
|
||||
|
||||
return left.flatMap((field) => {
|
||||
const rightValue = rightByLabel.get(field.label);
|
||||
if (rightValue == null || rightValue === field.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: field.label,
|
||||
leftValue: field.value,
|
||||
rightValue,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
|
||||
return (
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||
<h4 className="mt-2 text-base font-bold text-text">{document.title}</h4>
|
||||
<p className="mt-1 text-sm text-muted">{document.subtitle}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
{document.status}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{document.metaFields.map((field) => (
|
||||
<div key={`${label}-${field.label}`}>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
|
||||
<dd className="mt-1 text-sm text-text">{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{document.totalFields.map((field) => (
|
||||
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRevisionComparison({
|
||||
title,
|
||||
description,
|
||||
currentLabel,
|
||||
currentDocument,
|
||||
revisions,
|
||||
getRevisionDocument,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
currentLabel: string;
|
||||
currentDocument: ComparisonDocument;
|
||||
revisions: RevisionOption[];
|
||||
getRevisionDocument: (revisionId: string | "current") => ComparisonDocument;
|
||||
}) {
|
||||
const [leftRevisionId, setLeftRevisionId] = useState<string | "current">(revisions[0]?.id ?? "current");
|
||||
const [rightRevisionId, setRightRevisionId] = useState<string | "current">("current");
|
||||
|
||||
useEffect(() => {
|
||||
setLeftRevisionId((current) => (current === "current" || revisions.some((revision) => revision.id === current) ? current : revisions[0]?.id ?? "current"));
|
||||
}, [revisions]);
|
||||
|
||||
const leftDocument = getRevisionDocument(leftRevisionId);
|
||||
const rightDocument = getRevisionDocument(rightRevisionId);
|
||||
const diffRows = buildDiffRows(leftDocument, rightDocument);
|
||||
const metaChanges = buildFieldChanges(leftDocument.metaFields, rightDocument.metaFields);
|
||||
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
|
||||
|
||||
return (
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">{title}</p>
|
||||
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block min-w-[220px]">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</span>
|
||||
<select
|
||||
value={leftRevisionId}
|
||||
onChange={(event) => setLeftRevisionId(event.target.value as string | "current")}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{revisions.map((revision) => (
|
||||
<option key={revision.id} value={revision.id}>
|
||||
{revision.label} | {revision.meta}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block min-w-[220px]">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</span>
|
||||
<select
|
||||
value={rightRevisionId}
|
||||
onChange={(event) => setRightRevisionId(event.target.value as string | "current")}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
<option value="current">{currentLabel}</option>
|
||||
{revisions.map((revision) => (
|
||||
<option key={revision.id} value={revision.id}>
|
||||
{revision.label} | {revision.meta}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
<ComparisonCard label="Baseline" document={leftDocument} />
|
||||
<ComparisonCard label="Compare To" document={rightDocument} />
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
|
||||
{metaChanges.length === 0 && totalChanges.length === 0 ? (
|
||||
<div className="mt-3 text-sm text-muted">No header or total changes.</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{[...metaChanges, ...totalChanges].map((change) => (
|
||||
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
|
||||
<div className="mt-2 text-sm text-text">
|
||||
{change.leftValue} {"->"} {change.rightValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
|
||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
|
||||
</div>
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
|
||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
|
||||
</div>
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
|
||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
|
||||
</div>
|
||||
</div>
|
||||
{diffRows.length === 0 ? (
|
||||
<div className="mt-3 text-sm text-muted">No line-level changes.</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{diffRows.map((row) => (
|
||||
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</div>
|
||||
<div className="mt-1 text-sm text-text">
|
||||
{row.left ? `${row.left.quantity} | ${row.left.amountLabel}${row.left.totalLabel ? ` | ${row.left.totalLabel}` : ""}` : "Not present"}
|
||||
</div>
|
||||
{row.left?.subtitle ? <div className="mt-1 text-xs text-muted">{row.left.subtitle}</div> : null}
|
||||
{row.left?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.left.extraLabel}</div> : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</div>
|
||||
<div className="mt-1 text-sm text-text">
|
||||
{row.right ? `${row.right.quantity} | ${row.right.amountLabel}${row.right.totalLabel ? ` | ${row.right.totalLabel}` : ""}` : "Not present"}
|
||||
</div>
|
||||
{row.right?.subtitle ? <div className="mt-1 text-xs text-muted">{row.right.subtitle}</div> : null}
|
||||
{row.right?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.right.extraLabel}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { ConfirmActionDialog } from "./ConfirmActionDialog";
|
||||
|
||||
interface FileAttachmentsPanelProps {
|
||||
ownerType: string;
|
||||
@@ -41,6 +42,7 @@ export function FileAttachmentsPanel({
|
||||
const [status, setStatus] = useState("Loading attachments...");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
|
||||
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
|
||||
|
||||
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
|
||||
@@ -120,22 +122,23 @@ export function FileAttachmentsPanel({
|
||||
onAttachmentCountChange?.(nextAttachments.length);
|
||||
return nextAttachments;
|
||||
});
|
||||
setStatus("Attachment deleted.");
|
||||
setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setDeletingAttachmentId(null);
|
||||
setAttachmentPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<article className="surface-panel min-w-0">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">{title}</h4>
|
||||
<p className="mt-2 text-sm text-muted">{description}</p>
|
||||
<p className="section-kicker">{eyebrow}</p>
|
||||
<h4 className="text-lg font-bold text-text">{title}</h4>
|
||||
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||
</div>
|
||||
{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">
|
||||
@@ -144,26 +147,26 @@ export function FileAttachmentsPanel({
|
||||
</label>
|
||||
) : null}
|
||||
</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 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
You do not have permission to view file attachments.
|
||||
</div>
|
||||
) : attachments.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex flex-col gap-2 rounded-3xl border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"
|
||||
className="flex flex-col gap-2 rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-3">
|
||||
@@ -177,7 +180,7 @@ export function FileAttachmentsPanel({
|
||||
{canWriteFiles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(attachment)}
|
||||
onClick={() => setAttachmentPendingDelete(attachment)}
|
||||
disabled={deletingAttachmentId === attachment.id}
|
||||
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
||||
>
|
||||
@@ -189,6 +192,30 @@ export function FileAttachmentsPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmActionDialog
|
||||
open={attachmentPendingDelete != null}
|
||||
title="Delete attachment"
|
||||
description={
|
||||
attachmentPendingDelete
|
||||
? `Delete ${attachmentPendingDelete.originalName} from this record.`
|
||||
: "Delete this attachment."
|
||||
}
|
||||
impact="The file link will be removed from this record immediately."
|
||||
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
|
||||
confirmLabel="Delete file"
|
||||
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
|
||||
onClose={() => {
|
||||
if (!deletingAttachmentId) {
|
||||
setAttachmentPendingDelete(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (attachmentPendingDelete) {
|
||||
await handleDelete(attachmentPendingDelete);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ export function ThemeToggle() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="rounded-full border border-line/70 bg-surface px-4 py-2 text-sm font-semibold text-text transition hover:border-brand/60"
|
||||
className="w-full rounded-xl border border-line/70 bg-page/70 px-3 py-2 text-sm font-semibold text-text transition hover:border-brand/60"
|
||||
>
|
||||
{mode === "light" ? "Dark mode" : "Light mode"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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 {
|
||||
color-scheme: light;
|
||||
--font-family: "Manrope";
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import type {
|
||||
AdminDiagnosticsDto,
|
||||
AdminAuthSessionDto,
|
||||
BackupGuidanceDto,
|
||||
AdminPermissionOptionDto,
|
||||
AdminRoleDto,
|
||||
AdminRoleInput,
|
||||
SupportLogEntryDto,
|
||||
SupportLogFiltersDto,
|
||||
SupportLogListDto,
|
||||
SupportSnapshotDto,
|
||||
AdminUserDto,
|
||||
AdminUserInput,
|
||||
ApiResponse,
|
||||
CompanyProfileDto,
|
||||
CompanyProfileInput,
|
||||
FinanceCapexDto,
|
||||
FinanceCapexInput,
|
||||
FinanceCustomerPaymentDto,
|
||||
FinanceCustomerPaymentInput,
|
||||
FinanceDashboardDto,
|
||||
FinanceProfileDto,
|
||||
FinanceProfileInput,
|
||||
FileAttachmentDto,
|
||||
PlanningTimelineDto,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
} from "@mrp/shared";
|
||||
import type {
|
||||
CrmContactDto,
|
||||
@@ -23,6 +43,12 @@ import type {
|
||||
InventoryItemDetailDto,
|
||||
InventoryItemInput,
|
||||
InventoryItemOptionDto,
|
||||
InventorySkuBuilderPreviewDto,
|
||||
InventorySkuCatalogTreeDto,
|
||||
InventorySkuFamilyDto,
|
||||
InventorySkuFamilyInput,
|
||||
InventorySkuNodeDto,
|
||||
InventorySkuNodeInput,
|
||||
InventoryReservationInput,
|
||||
InventoryItemStatus,
|
||||
InventoryItemSummaryDto,
|
||||
@@ -42,15 +68,23 @@ import type {
|
||||
WorkOrderCompletionInput,
|
||||
WorkOrderDetailDto,
|
||||
WorkOrderInput,
|
||||
WorkOrderOperationAssignmentInput,
|
||||
WorkOrderOperationExecutionInput,
|
||||
WorkOrderOperationLaborEntryInput,
|
||||
WorkOrderOperationScheduleInput,
|
||||
WorkOrderOperationTimerInput,
|
||||
WorkOrderMaterialIssueInput,
|
||||
WorkOrderStatus,
|
||||
WorkOrderStatusUpdateInput,
|
||||
WorkOrderSummaryDto,
|
||||
ManufacturingUserOptionDto,
|
||||
} from "@mrp/shared";
|
||||
import type {
|
||||
ProjectCustomerOptionDto,
|
||||
ProjectDetailDto,
|
||||
ProjectDocumentOptionDto,
|
||||
ProjectInput,
|
||||
ProjectMilestoneStatusUpdateInput,
|
||||
ProjectOwnerOptionDto,
|
||||
ProjectPriority,
|
||||
ProjectShipmentOptionDto,
|
||||
@@ -59,8 +93,10 @@ import type {
|
||||
} from "@mrp/shared/dist/projects/types.js";
|
||||
import type {
|
||||
SalesCustomerOptionDto,
|
||||
DemandPlanningRollupDto,
|
||||
SalesDocumentDetailDto,
|
||||
SalesDocumentInput,
|
||||
SalesOrderPlanningDto,
|
||||
SalesDocumentRevisionDto,
|
||||
SalesDocumentStatus,
|
||||
SalesDocumentSummaryDto,
|
||||
@@ -68,6 +104,7 @@ import type {
|
||||
import type {
|
||||
PurchaseOrderDetailDto,
|
||||
PurchaseOrderInput,
|
||||
PurchaseOrderRevisionDto,
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderSummaryDto,
|
||||
PurchaseVendorOptionDto,
|
||||
@@ -77,6 +114,7 @@ import type {
|
||||
ShipmentDetailDto,
|
||||
ShipmentInput,
|
||||
ShipmentOrderOptionDto,
|
||||
ShipmentPickInput,
|
||||
ShipmentStatus,
|
||||
ShipmentSummaryDto,
|
||||
} from "@mrp/shared/dist/shipping/types.js";
|
||||
@@ -127,6 +165,73 @@ export const api = {
|
||||
me(token: string) {
|
||||
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
||||
},
|
||||
logout(token: string) {
|
||||
return request<LogoutResponse>("/api/v1/auth/logout", { method: "POST" }, token);
|
||||
},
|
||||
getAdminDiagnostics(token: string) {
|
||||
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
|
||||
},
|
||||
getBackupGuidance(token: string) {
|
||||
return request<BackupGuidanceDto>("/api/v1/admin/backup-guidance", undefined, token);
|
||||
},
|
||||
getSupportSnapshot(token: string) {
|
||||
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
|
||||
},
|
||||
getSupportSnapshotWithFilters(token: string, filters?: SupportLogFiltersDto) {
|
||||
return request<SupportSnapshotDto>(
|
||||
`/api/v1/admin/support-snapshot${buildQueryString({
|
||||
level: filters?.level,
|
||||
source: filters?.source,
|
||||
query: filters?.query,
|
||||
start: filters?.start,
|
||||
end: filters?.end,
|
||||
limit: filters?.limit?.toString(),
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getSupportLogs(token: string, filters?: SupportLogFiltersDto) {
|
||||
return request<SupportLogListDto>(
|
||||
`/api/v1/admin/support-logs${buildQueryString({
|
||||
level: filters?.level,
|
||||
source: filters?.source,
|
||||
query: filters?.query,
|
||||
start: filters?.start,
|
||||
end: filters?.end,
|
||||
limit: filters?.limit?.toString(),
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getAdminPermissions(token: string) {
|
||||
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
|
||||
},
|
||||
getAdminRoles(token: string) {
|
||||
return request<AdminRoleDto[]>("/api/v1/admin/roles", undefined, token);
|
||||
},
|
||||
createAdminRole(token: string, payload: AdminRoleInput) {
|
||||
return request<AdminRoleDto>("/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateAdminRole(token: string, roleId: string, payload: AdminRoleInput) {
|
||||
return request<AdminRoleDto>(`/api/v1/admin/roles/${roleId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
getAdminUsers(token: string) {
|
||||
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
|
||||
},
|
||||
getAdminSessions(token: string) {
|
||||
return request<AdminAuthSessionDto[]>("/api/v1/admin/sessions", undefined, token);
|
||||
},
|
||||
revokeAdminSession(token: string, sessionId: string) {
|
||||
return request<LogoutResponse>(`/api/v1/admin/sessions/${sessionId}/revoke`, { method: "POST" }, token);
|
||||
},
|
||||
createAdminUser(token: string, payload: AdminUserInput) {
|
||||
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateAdminUser(token: string, userId: string, payload: AdminUserInput) {
|
||||
return request<AdminUserDto>(`/api/v1/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
getCompanyProfile(token: string) {
|
||||
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
|
||||
},
|
||||
@@ -191,6 +296,21 @@ export const api = {
|
||||
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(
|
||||
token: string,
|
||||
filters?: {
|
||||
@@ -347,6 +467,38 @@ export const api = {
|
||||
getInventoryItemOptions(token: string) {
|
||||
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
|
||||
},
|
||||
getInventorySkuFamilies(token: string) {
|
||||
return request<InventorySkuFamilyDto[]>("/api/v1/inventory/sku/families", undefined, token);
|
||||
},
|
||||
getInventorySkuCatalog(token: string) {
|
||||
return request<InventorySkuCatalogTreeDto>("/api/v1/inventory/sku/catalog", undefined, token);
|
||||
},
|
||||
getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) {
|
||||
return request<InventorySkuNodeDto[]>(
|
||||
`/api/v1/inventory/sku/nodes${buildQueryString({
|
||||
familyId,
|
||||
parentNodeId: parentNodeId ?? undefined,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) {
|
||||
return request<InventorySkuBuilderPreviewDto>(
|
||||
`/api/v1/inventory/sku/preview${buildQueryString({
|
||||
familyId,
|
||||
nodeId: nodeId ?? undefined,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) {
|
||||
return request<InventorySkuFamilyDto>("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
createInventorySkuNode(token: string, payload: InventorySkuNodeInput) {
|
||||
return request<InventorySkuNodeDto>("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
getWarehouseLocationOptions(token: string) {
|
||||
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
|
||||
},
|
||||
@@ -451,6 +603,13 @@ export const api = {
|
||||
updateProject(token: string, projectId: string, payload: ProjectInput) {
|
||||
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) {
|
||||
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
|
||||
},
|
||||
@@ -484,12 +643,18 @@ export const api = {
|
||||
getManufacturingProjectOptions(token: string) {
|
||||
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) {
|
||||
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
|
||||
},
|
||||
createManufacturingStation(token: string, payload: ManufacturingStationInput) {
|
||||
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 }) {
|
||||
return request<WorkOrderSummaryDto[]>(
|
||||
`/api/v1/manufacturing/work-orders${buildQueryString({
|
||||
@@ -511,10 +676,45 @@ export const api = {
|
||||
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
|
||||
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>(
|
||||
`/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
|
||||
);
|
||||
},
|
||||
@@ -589,6 +789,12 @@ export const api = {
|
||||
getSalesOrder(token: string, orderId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token);
|
||||
},
|
||||
getSalesOrderPlanning(token: string, orderId: string) {
|
||||
return request<SalesOrderPlanningDto>(`/api/v1/sales/orders/${orderId}/planning`, undefined, token);
|
||||
},
|
||||
getDemandPlanningRollup(token: string) {
|
||||
return request<DemandPlanningRollupDto>("/api/v1/sales/planning-rollup", undefined, token);
|
||||
},
|
||||
createSalesOrder(token: string, payload: SalesDocumentInput) {
|
||||
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
@@ -622,6 +828,9 @@ export const api = {
|
||||
getPurchaseOrder(token: string, orderId: string) {
|
||||
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, undefined, token);
|
||||
},
|
||||
getPurchaseOrderRevisions(token: string, orderId: string) {
|
||||
return request<PurchaseOrderRevisionDto[]>(`/api/v1/purchasing/orders/${orderId}/revisions`, undefined, token);
|
||||
},
|
||||
createPurchaseOrder(token: string, payload: PurchaseOrderInput) {
|
||||
return request<PurchaseOrderDetailDto>("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
@@ -672,6 +881,9 @@ export const api = {
|
||||
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) {
|
||||
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/packing-slip.pdf`, {
|
||||
headers: {
|
||||
|
||||
@@ -9,40 +9,127 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
import { DashboardPage } from "./modules/dashboard/DashboardPage";
|
||||
import { LoginPage } from "./modules/login/LoginPage";
|
||||
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage";
|
||||
import { CrmDetailPage } from "./modules/crm/CrmDetailPage";
|
||||
import { CrmFormPage } from "./modules/crm/CrmFormPage";
|
||||
import { CustomersPage } from "./modules/crm/CustomersPage";
|
||||
import { VendorsPage } from "./modules/crm/VendorsPage";
|
||||
import { GanttPage } from "./modules/gantt/GanttPage";
|
||||
import { InventoryDetailPage } from "./modules/inventory/InventoryDetailPage";
|
||||
import { InventoryFormPage } from "./modules/inventory/InventoryFormPage";
|
||||
import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage";
|
||||
import { ManufacturingPage } from "./modules/manufacturing/ManufacturingPage";
|
||||
import { WorkOrderDetailPage } from "./modules/manufacturing/WorkOrderDetailPage";
|
||||
import { WorkOrderFormPage } from "./modules/manufacturing/WorkOrderFormPage";
|
||||
import { PurchaseDetailPage } from "./modules/purchasing/PurchaseDetailPage";
|
||||
import { PurchaseFormPage } from "./modules/purchasing/PurchaseFormPage";
|
||||
import { PurchaseListPage } from "./modules/purchasing/PurchaseListPage";
|
||||
import { ProjectDetailPage } from "./modules/projects/ProjectDetailPage";
|
||||
import { ProjectFormPage } from "./modules/projects/ProjectFormPage";
|
||||
import { ProjectsPage } from "./modules/projects/ProjectsPage";
|
||||
import { WarehouseDetailPage } from "./modules/inventory/WarehouseDetailPage";
|
||||
import { WarehouseFormPage } from "./modules/inventory/WarehouseFormPage";
|
||||
import { WarehousesPage } from "./modules/inventory/WarehousesPage";
|
||||
import { SalesDetailPage } from "./modules/sales/SalesDetailPage";
|
||||
import { SalesFormPage } from "./modules/sales/SalesFormPage";
|
||||
import { SalesListPage } from "./modules/sales/SalesListPage";
|
||||
import { ShipmentDetailPage } from "./modules/shipping/ShipmentDetailPage";
|
||||
import { ShipmentFormPage } from "./modules/shipping/ShipmentFormPage";
|
||||
import { ShipmentListPage } from "./modules/shipping/ShipmentListPage";
|
||||
import { ThemeProvider } from "./theme/ThemeProvider";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const CompanySettingsPage = React.lazy(() =>
|
||||
import("./modules/settings/CompanySettingsPage").then((module) => ({ default: module.CompanySettingsPage }))
|
||||
);
|
||||
const AdminDiagnosticsPage = React.lazy(() =>
|
||||
import("./modules/settings/AdminDiagnosticsPage").then((module) => ({ default: module.AdminDiagnosticsPage }))
|
||||
);
|
||||
const UserManagementPage = React.lazy(() =>
|
||||
import("./modules/settings/UserManagementPage").then((module) => ({ default: module.UserManagementPage }))
|
||||
);
|
||||
const CustomersPage = React.lazy(() =>
|
||||
import("./modules/crm/CustomersPage").then((module) => ({ default: module.CustomersPage }))
|
||||
);
|
||||
const VendorsPage = React.lazy(() =>
|
||||
import("./modules/crm/VendorsPage").then((module) => ({ default: module.VendorsPage }))
|
||||
);
|
||||
const CrmDetailPage = React.lazy(() =>
|
||||
import("./modules/crm/CrmDetailPage").then((module) => ({ default: module.CrmDetailPage }))
|
||||
);
|
||||
const CrmFormPage = React.lazy(() =>
|
||||
import("./modules/crm/CrmFormPage").then((module) => ({ default: module.CrmFormPage }))
|
||||
);
|
||||
const InventoryItemsPage = React.lazy(() =>
|
||||
import("./modules/inventory/InventoryItemsPage").then((module) => ({ default: module.InventoryItemsPage }))
|
||||
);
|
||||
const InventoryDetailPage = React.lazy(() =>
|
||||
import("./modules/inventory/InventoryDetailPage").then((module) => ({ default: module.InventoryDetailPage }))
|
||||
);
|
||||
const InventoryFormPage = React.lazy(() =>
|
||||
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
|
||||
);
|
||||
const InventorySkuMasterPage = React.lazy(() =>
|
||||
import("./modules/inventory/InventorySkuMasterPage").then((module) => ({ default: module.InventorySkuMasterPage }))
|
||||
);
|
||||
const WarehousesPage = React.lazy(() =>
|
||||
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
|
||||
);
|
||||
const WarehouseDetailPage = React.lazy(() =>
|
||||
import("./modules/inventory/WarehouseDetailPage").then((module) => ({ default: module.WarehouseDetailPage }))
|
||||
);
|
||||
const WarehouseFormPage = React.lazy(() =>
|
||||
import("./modules/inventory/WarehouseFormPage").then((module) => ({ default: module.WarehouseFormPage }))
|
||||
);
|
||||
const ProjectsPage = React.lazy(() =>
|
||||
import("./modules/projects/ProjectsPage").then((module) => ({ default: module.ProjectsPage }))
|
||||
);
|
||||
const ProjectDetailPage = React.lazy(() =>
|
||||
import("./modules/projects/ProjectDetailPage").then((module) => ({ default: module.ProjectDetailPage }))
|
||||
);
|
||||
const ProjectFormPage = React.lazy(() =>
|
||||
import("./modules/projects/ProjectFormPage").then((module) => ({ default: module.ProjectFormPage }))
|
||||
);
|
||||
const ManufacturingPage = React.lazy(() =>
|
||||
import("./modules/manufacturing/ManufacturingPage").then((module) => ({ default: module.ManufacturingPage }))
|
||||
);
|
||||
const WorkOrderDetailPage = React.lazy(() =>
|
||||
import("./modules/manufacturing/WorkOrderDetailPage").then((module) => ({ default: module.WorkOrderDetailPage }))
|
||||
);
|
||||
const WorkOrderFormPage = React.lazy(() =>
|
||||
import("./modules/manufacturing/WorkOrderFormPage").then((module) => ({ default: module.WorkOrderFormPage }))
|
||||
);
|
||||
const PurchaseListPage = React.lazy(() =>
|
||||
import("./modules/purchasing/PurchaseListPage").then((module) => ({ default: module.PurchaseListPage }))
|
||||
);
|
||||
const PurchaseDetailPage = React.lazy(() =>
|
||||
import("./modules/purchasing/PurchaseDetailPage").then((module) => ({ default: module.PurchaseDetailPage }))
|
||||
);
|
||||
const PurchaseFormPage = React.lazy(() =>
|
||||
import("./modules/purchasing/PurchaseFormPage").then((module) => ({ default: module.PurchaseFormPage }))
|
||||
);
|
||||
const SalesListPage = React.lazy(() =>
|
||||
import("./modules/sales/SalesListPage").then((module) => ({ default: module.SalesListPage }))
|
||||
);
|
||||
const SalesDetailPage = React.lazy(() =>
|
||||
import("./modules/sales/SalesDetailPage").then((module) => ({ default: module.SalesDetailPage }))
|
||||
);
|
||||
const SalesFormPage = React.lazy(() =>
|
||||
import("./modules/sales/SalesFormPage").then((module) => ({ default: module.SalesFormPage }))
|
||||
);
|
||||
const ShipmentListPage = React.lazy(() =>
|
||||
import("./modules/shipping/ShipmentListPage").then((module) => ({ default: module.ShipmentListPage }))
|
||||
);
|
||||
const ShipmentDetailPage = React.lazy(() =>
|
||||
import("./modules/shipping/ShipmentDetailPage").then((module) => ({ default: module.ShipmentDetailPage }))
|
||||
);
|
||||
const ShipmentFormPage = React.lazy(() =>
|
||||
import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage }))
|
||||
);
|
||||
const FinancePage = React.lazy(() =>
|
||||
import("./modules/finance/FinancePage").then((module) => ({ default: module.FinancePage }))
|
||||
);
|
||||
const WorkbenchPage = React.lazy(() =>
|
||||
import("./modules/workbench/WorkbenchPage").then((module) => ({ default: module.WorkbenchPage }))
|
||||
);
|
||||
const LandingPage = React.lazy(() =>
|
||||
import("./modules/landing/LandingPage").then((module) => ({ default: module.LandingPage }))
|
||||
);
|
||||
const DarkLandingPage = React.lazy(() =>
|
||||
import("./modules/landing/LandingPage").then((module) => ({ default: module.DarkLandingPage }))
|
||||
);
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
|
||||
Loading module...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function lazyElement(element: React.ReactNode) {
|
||||
return <React.Suspense fallback={<RouteFallback />}>{element}</React.Suspense>;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/landing", element: lazyElement(<LandingPage />) },
|
||||
{ path: "/darklanding", element: lazyElement(<DarkLandingPage />) },
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
@@ -52,121 +139,136 @@ const router = createBrowserRouter([
|
||||
{ path: "/", element: <DashboardPage /> },
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
|
||||
children: [{ path: "/settings/company", element: <CompanySettingsPage /> }],
|
||||
children: [{ path: "/settings/company", element: lazyElement(<CompanySettingsPage />) }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />,
|
||||
children: [
|
||||
{ path: "/settings/admin-diagnostics", element: lazyElement(<AdminDiagnosticsPage />) },
|
||||
{ path: "/settings/users", element: lazyElement(<UserManagementPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
|
||||
children: [
|
||||
{ path: "/crm/customers", element: <CustomersPage /> },
|
||||
{ path: "/crm/customers/:customerId", element: <CrmDetailPage entity="customer" /> },
|
||||
{ path: "/crm/vendors", element: <VendorsPage /> },
|
||||
{ path: "/crm/vendors/:vendorId", element: <CrmDetailPage entity="vendor" /> },
|
||||
{ path: "/crm/customers", element: lazyElement(<CustomersPage />) },
|
||||
{ path: "/crm/customers/:customerId", element: lazyElement(<CrmDetailPage entity="customer" />) },
|
||||
{ path: "/crm/vendors", element: lazyElement(<VendorsPage />) },
|
||||
{ path: "/crm/vendors/:vendorId", element: lazyElement(<CrmDetailPage entity="vendor" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.inventoryRead]} />,
|
||||
children: [
|
||||
{ path: "/inventory/items", element: <InventoryItemsPage /> },
|
||||
{ path: "/inventory/items/:itemId", element: <InventoryDetailPage /> },
|
||||
{ path: "/inventory/warehouses", element: <WarehousesPage /> },
|
||||
{ path: "/inventory/warehouses/:warehouseId", element: <WarehouseDetailPage /> },
|
||||
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
|
||||
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
|
||||
{ path: "/inventory/sku-master", element: lazyElement(<InventorySkuMasterPage />) },
|
||||
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
|
||||
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />,
|
||||
children: [
|
||||
{ path: "/projects", element: <ProjectsPage /> },
|
||||
{ path: "/projects/:projectId", element: <ProjectDetailPage /> },
|
||||
{ path: "/projects", element: lazyElement(<ProjectsPage />) },
|
||||
{ path: "/projects/:projectId", element: lazyElement(<ProjectDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />,
|
||||
children: [
|
||||
{ path: "/manufacturing/work-orders", element: <ManufacturingPage /> },
|
||||
{ path: "/manufacturing/work-orders/:workOrderId", element: <WorkOrderDetailPage /> },
|
||||
{ path: "/manufacturing/work-orders", element: lazyElement(<ManufacturingPage />) },
|
||||
{ path: "/manufacturing/work-orders/:workOrderId", element: lazyElement(<WorkOrderDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
|
||||
children: [
|
||||
{ path: "/purchasing/orders", element: <PurchaseListPage /> },
|
||||
{ path: "/purchasing/orders/:orderId", element: <PurchaseDetailPage /> },
|
||||
{ path: "/purchasing/orders", element: lazyElement(<PurchaseListPage />) },
|
||||
{ path: "/purchasing/orders/:orderId", element: lazyElement(<PurchaseDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
|
||||
children: [
|
||||
{ path: "/sales/quotes", element: <SalesListPage entity="quote" /> },
|
||||
{ path: "/sales/quotes/:quoteId", element: <SalesDetailPage entity="quote" /> },
|
||||
{ path: "/sales/orders", element: <SalesListPage entity="order" /> },
|
||||
{ path: "/sales/orders/:orderId", element: <SalesDetailPage entity="order" /> },
|
||||
{ path: "/sales/quotes", element: lazyElement(<SalesListPage entity="quote" />) },
|
||||
{ path: "/sales/quotes/:quoteId", element: lazyElement(<SalesDetailPage entity="quote" />) },
|
||||
{ path: "/sales/orders", element: lazyElement(<SalesListPage entity="order" />) },
|
||||
{ path: "/sales/orders/:orderId", element: lazyElement(<SalesDetailPage entity="order" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.shippingRead]} />,
|
||||
children: [
|
||||
{ path: "/shipping/shipments", element: <ShipmentListPage /> },
|
||||
{ path: "/shipping/shipments/:shipmentId", element: <ShipmentDetailPage /> },
|
||||
{ path: "/shipping/shipments", element: lazyElement(<ShipmentListPage />) },
|
||||
{ path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.financeRead]} />,
|
||||
children: [{ path: "/finance", element: lazyElement(<FinancePage />) }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
|
||||
children: [
|
||||
{ path: "/crm/customers/new", element: <CrmFormPage entity="customer" mode="create" /> },
|
||||
{ path: "/crm/customers/:customerId/edit", element: <CrmFormPage entity="customer" mode="edit" /> },
|
||||
{ path: "/crm/vendors/new", element: <CrmFormPage entity="vendor" mode="create" /> },
|
||||
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
|
||||
{ path: "/crm/customers/new", element: lazyElement(<CrmFormPage entity="customer" mode="create" />) },
|
||||
{ path: "/crm/customers/:customerId/edit", element: lazyElement(<CrmFormPage entity="customer" mode="edit" />) },
|
||||
{ path: "/crm/vendors/new", element: lazyElement(<CrmFormPage entity="vendor" mode="create" />) },
|
||||
{ path: "/crm/vendors/:vendorId/edit", element: lazyElement(<CrmFormPage entity="vendor" mode="edit" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />,
|
||||
children: [
|
||||
{ path: "/projects/new", element: <ProjectFormPage mode="create" /> },
|
||||
{ path: "/projects/:projectId/edit", element: <ProjectFormPage mode="edit" /> },
|
||||
{ path: "/projects/new", element: lazyElement(<ProjectFormPage mode="create" />) },
|
||||
{ path: "/projects/:projectId/edit", element: lazyElement(<ProjectFormPage mode="edit" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingWrite]} />,
|
||||
children: [
|
||||
{ path: "/manufacturing/work-orders/new", element: <WorkOrderFormPage mode="create" /> },
|
||||
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: <WorkOrderFormPage mode="edit" /> },
|
||||
{ path: "/manufacturing/work-orders/new", element: lazyElement(<WorkOrderFormPage mode="create" />) },
|
||||
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: lazyElement(<WorkOrderFormPage mode="edit" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
|
||||
children: [
|
||||
{ path: "/purchasing/orders/new", element: <PurchaseFormPage mode="create" /> },
|
||||
{ path: "/purchasing/orders/:orderId/edit", element: <PurchaseFormPage mode="edit" /> },
|
||||
{ path: "/purchasing/orders/new", element: lazyElement(<PurchaseFormPage mode="create" />) },
|
||||
{ path: "/purchasing/orders/:orderId/edit", element: lazyElement(<PurchaseFormPage mode="edit" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />,
|
||||
children: [
|
||||
{ path: "/sales/quotes/new", element: <SalesFormPage entity="quote" mode="create" /> },
|
||||
{ path: "/sales/quotes/:quoteId/edit", element: <SalesFormPage entity="quote" mode="edit" /> },
|
||||
{ path: "/sales/orders/new", element: <SalesFormPage entity="order" mode="create" /> },
|
||||
{ path: "/sales/orders/:orderId/edit", element: <SalesFormPage entity="order" mode="edit" /> },
|
||||
{ path: "/sales/quotes/new", element: lazyElement(<SalesFormPage entity="quote" mode="create" />) },
|
||||
{ path: "/sales/quotes/:quoteId/edit", element: lazyElement(<SalesFormPage entity="quote" mode="edit" />) },
|
||||
{ path: "/sales/orders/new", element: lazyElement(<SalesFormPage entity="order" mode="create" />) },
|
||||
{ path: "/sales/orders/:orderId/edit", element: lazyElement(<SalesFormPage entity="order" mode="edit" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.shippingWrite]} />,
|
||||
children: [
|
||||
{ path: "/shipping/shipments/new", element: <ShipmentFormPage mode="create" /> },
|
||||
{ path: "/shipping/shipments/:shipmentId/edit", element: <ShipmentFormPage mode="edit" /> },
|
||||
{ path: "/shipping/shipments/new", element: lazyElement(<ShipmentFormPage mode="create" />) },
|
||||
{ path: "/shipping/shipments/:shipmentId/edit", element: lazyElement(<ShipmentFormPage mode="edit" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
|
||||
children: [
|
||||
{ path: "/inventory/items/new", element: <InventoryFormPage mode="create" /> },
|
||||
{ path: "/inventory/items/:itemId/edit", element: <InventoryFormPage mode="edit" /> },
|
||||
{ path: "/inventory/warehouses/new", element: <WarehouseFormPage mode="create" /> },
|
||||
{ path: "/inventory/warehouses/:warehouseId/edit", element: <WarehouseFormPage mode="edit" /> },
|
||||
{ path: "/inventory/items/new", element: lazyElement(<InventoryFormPage mode="create" />) },
|
||||
{ path: "/inventory/items/:itemId/edit", element: lazyElement(<InventoryFormPage mode="edit" />) },
|
||||
{ path: "/inventory/warehouses/new", element: lazyElement(<WarehouseFormPage mode="create" />) },
|
||||
{ path: "/inventory/warehouses/:warehouseId/edit", element: lazyElement(<WarehouseFormPage mode="edit" />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
|
||||
children: [{ path: "/planning/gantt", element: <GanttPage /> }],
|
||||
children: [
|
||||
{ path: "/planning/workbench", element: lazyElement(<WorkbenchPage />) },
|
||||
{ path: "/planning/gantt", element: <Navigate to="/planning/workbench" replace /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -186,3 +288,4 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -58,21 +58,20 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">People on this account</h4>
|
||||
<div className="mt-5 space-y-3">
|
||||
<article className="surface-panel min-w-0">
|
||||
<p className="section-kicker">CONTACTS</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{contacts.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No contacts have been added yet.
|
||||
</div>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<div key={contact.id} className="rounded-3xl border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div key={contact.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<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 className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
||||
</div>
|
||||
@@ -86,22 +85,22 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<form className="mt-3 space-y-3" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<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
|
||||
value={form.fullName}
|
||||
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 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
|
||||
value={form.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) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -111,24 +110,24 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
||||
</select>
|
||||
</label>
|
||||
<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
|
||||
type="email"
|
||||
value={form.email}
|
||||
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 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
|
||||
value={form.phone}
|
||||
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>
|
||||
</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
|
||||
type="checkbox"
|
||||
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>
|
||||
</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>
|
||||
<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"
|
||||
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"}
|
||||
</button>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
}, [config.singularLabel, entity, recordId, token]);
|
||||
|
||||
if (!record) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
function updateContactEntryField<Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) {
|
||||
@@ -111,21 +111,19 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-text">{record.name}</h3>
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<p className="section-kicker">CRM DETAIL</p>
|
||||
<h3 className="module-title">{record.name}</h3>
|
||||
<div className="mt-2.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CrmStatusBadge status={record.status} />
|
||||
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted">UPDATED {new Date(record.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
@@ -146,8 +144,8 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact</p>
|
||||
<article className="surface-panel min-w-0">
|
||||
<p className="section-kicker">CONTACT</p>
|
||||
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
|
||||
@@ -176,8 +174,8 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Internal Notes</p>
|
||||
<article className="surface-panel min-w-0">
|
||||
<p className="section-kicker">INTERNAL NOTES</p>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">
|
||||
{record.notes || "No internal notes recorded for this account yet."}
|
||||
</p>
|
||||
@@ -218,36 +216,35 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<section className="grid gap-2 xl:grid-cols-4">
|
||||
<article className="surface-panel-tight">
|
||||
<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">
|
||||
{record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"}
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Entries</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.length}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account Contacts</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Attachments</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div>
|
||||
</article>
|
||||
</section>
|
||||
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">End customers under this reseller</h4>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">HIERARCHY</p>
|
||||
<div className="mt-3 grid gap-2 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{record.childCustomers?.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/crm/customers/${child.id}`}
|
||||
className="rounded-3xl border border-line/70 bg-page/60 px-2 py-2 transition hover:border-brand/50 hover:bg-page/80"
|
||||
className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 transition hover:border-brand/50 hover:bg-page/80"
|
||||
>
|
||||
<div className="text-sm font-semibold text-text">{child.name}</div>
|
||||
<div className="mt-2">
|
||||
@@ -259,11 +256,10 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
</section>
|
||||
) : null}
|
||||
{entity === "vendor" ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Activity</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
|
||||
<p className="section-kicker">PURCHASING ACTIVITY</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManage ? (
|
||||
@@ -277,15 +273,15 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
{relatedPurchaseOrders.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase orders exist for this vendor yet.</div>
|
||||
<div className="mt-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) => (
|
||||
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<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 className="text-sm font-semibold text-text">${order.total.toFixed(2)}</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)]">
|
||||
{canManage ? (
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact History</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Add timeline entry</h4>
|
||||
<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">
|
||||
<article className="surface-panel min-w-0">
|
||||
<p className="section-kicker">CONTACT HISTORY</p>
|
||||
<div className="mt-3">
|
||||
<CrmContactEntryForm
|
||||
form={contactEntryForm}
|
||||
isSaving={isSavingContactEntry}
|
||||
@@ -336,17 +328,16 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timeline</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Recent interactions</h4>
|
||||
<article className="surface-panel min-w-0">
|
||||
<p className="section-kicker">TIMELINE</p>
|
||||
{record.contactHistory.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No contact history has been recorded for this account yet.
|
||||
<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 recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{record.contactHistory.map((entry) => (
|
||||
<article key={entry.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<article key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -389,3 +380,4 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,17 +110,14 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">
|
||||
<p className="section-kicker">CRM EDITOR</p>
|
||||
<h3 className="module-title">
|
||||
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
|
||||
</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>
|
||||
<Link
|
||||
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
|
||||
@@ -130,9 +127,9 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="space-y-3 surface-panel">
|
||||
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
|
||||
<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>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -146,3 +143,4 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,14 +55,11 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
||||
}, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">{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>
|
||||
<p className="section-kicker">CRM</p>
|
||||
<h3 className="module-title">{config.collectionLabel}</h3>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Link
|
||||
@@ -73,7 +70,7 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr_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">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||
<input
|
||||
@@ -137,13 +134,13 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<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 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
|
||||
{config.emptyMessage}
|
||||
</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">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
@@ -209,3 +206,4 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { ApiError, api } from "../../lib/api";
|
||||
@@ -16,6 +17,7 @@ interface DashboardSnapshot {
|
||||
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
|
||||
shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
|
||||
projects: Awaited<ReturnType<typeof api.getProjects>> | null;
|
||||
planningRollup: DemandPlanningRollupDto | null;
|
||||
refreshedAt: string;
|
||||
}
|
||||
|
||||
@@ -31,31 +33,75 @@ function formatCurrency(value: number) {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "No recent activity";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function sumNumber(values: number[]) {
|
||||
return values.reduce((total, value) => total + value, 0);
|
||||
}
|
||||
|
||||
function formatPercent(value: number, total: number) {
|
||||
if (total <= 0) {
|
||||
return "0%";
|
||||
}
|
||||
|
||||
return `${Math.round((value / total) * 100)}%`;
|
||||
}
|
||||
|
||||
function ProgressBar({
|
||||
value,
|
||||
total,
|
||||
tone,
|
||||
}: {
|
||||
value: number;
|
||||
total: number;
|
||||
tone: string;
|
||||
}) {
|
||||
const width = total > 0 ? Math.max(6, Math.round((value / total) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-page/80">
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${Math.min(width, 100)}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackedBar({
|
||||
segments,
|
||||
}: {
|
||||
segments: Array<{ value: number; tone: string }>;
|
||||
}) {
|
||||
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-page/80">
|
||||
{segments.map((segment, index) => {
|
||||
const width = total > 0 ? (segment.value / total) * 100 : 0;
|
||||
return <div key={`${segment.tone}-${index}`} className={segment.tone} style={{ width: `${width}%` }} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardCard({
|
||||
eyebrow,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
eyebrow: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<article className={`surface-panel ${className}`.trim()}>
|
||||
<p className="section-kicker">{eyebrow}</p>
|
||||
{children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
|
||||
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
|
||||
const canReadPlanning = hasPermission(user?.permissions, permissions.ganttRead);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user) {
|
||||
@@ -89,6 +135,7 @@ export function DashboardPage() {
|
||||
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
|
||||
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
|
||||
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
|
||||
canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (!isMounted) {
|
||||
@@ -112,6 +159,7 @@ export function DashboardPage() {
|
||||
orders: results[7].status === "fulfilled" ? results[7].value : null,
|
||||
shipments: results[8].status === "fulfilled" ? results[8].value : null,
|
||||
projects: results[9].status === "fulfilled" ? results[9].value : null,
|
||||
planningRollup: results[10].status === "fulfilled" ? results[10].value : null,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
});
|
||||
setIsLoading(false);
|
||||
@@ -142,27 +190,17 @@ export function DashboardPage() {
|
||||
const orders = snapshot?.orders ?? [];
|
||||
const shipments = snapshot?.shipments ?? [];
|
||||
const projects = snapshot?.projects ?? [];
|
||||
|
||||
const accessibleModules = [
|
||||
snapshot?.customers !== null || snapshot?.vendors !== null,
|
||||
snapshot?.items !== null || snapshot?.warehouses !== null,
|
||||
snapshot?.purchaseOrders !== null,
|
||||
snapshot?.workOrders !== null,
|
||||
snapshot?.quotes !== null || snapshot?.orders !== null,
|
||||
snapshot?.shipments !== null,
|
||||
snapshot?.projects !== null,
|
||||
canReadPlanning,
|
||||
].filter(Boolean).length;
|
||||
const planningRollup = snapshot?.planningRollup;
|
||||
|
||||
const customerCount = customers.length;
|
||||
const resellerCount = customers.filter((customer) => customer.isReseller).length;
|
||||
const activeCustomerCount = customers.filter((customer) => customer.lifecycleStage === "ACTIVE").length;
|
||||
const resellerCount = customers.filter((customer) => customer.isReseller).length;
|
||||
const strategicCustomerCount = customers.filter((customer) => customer.strategicAccount).length;
|
||||
const vendorCount = vendors.length;
|
||||
|
||||
const itemCount = items.length;
|
||||
const assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length;
|
||||
const activeItemCount = items.filter((item) => item.status === "ACTIVE").length;
|
||||
const assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length;
|
||||
const obsoleteItemCount = items.filter((item) => item.status === "OBSOLETE").length;
|
||||
const warehouseCount = warehouses.length;
|
||||
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
|
||||
@@ -170,16 +208,19 @@ export function DashboardPage() {
|
||||
const purchaseOrderCount = purchaseOrders.length;
|
||||
const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length;
|
||||
const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
|
||||
const closedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "CLOSED").length;
|
||||
const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total));
|
||||
|
||||
const workOrderCount = workOrders.length;
|
||||
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
|
||||
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
|
||||
const inProgressWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "IN_PROGRESS").length;
|
||||
const overdueWorkOrderCount = workOrders.filter((workOrder) => workOrder.dueDate && workOrder.status !== "COMPLETE" && workOrder.status !== "CANCELLED" && new Date(workOrder.dueDate).getTime() < Date.now()).length;
|
||||
|
||||
const quoteCount = quotes.length;
|
||||
const orderCount = orders.length;
|
||||
const draftQuoteCount = quotes.filter((quote) => quote.status === "DRAFT").length;
|
||||
const approvedQuoteCount = quotes.filter((quote) => quote.status === "APPROVED").length;
|
||||
const issuedOrderCount = orders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
|
||||
const quoteValue = sumNumber(quotes.map((quote) => quote.total));
|
||||
const orderValue = sumNumber(orders.map((order) => order.total));
|
||||
@@ -200,447 +241,349 @@ export function DashboardPage() {
|
||||
return new Date(project.dueDate).getTime() < Date.now();
|
||||
}).length;
|
||||
|
||||
const lastActivityAt = [
|
||||
...customers.map((customer) => customer.updatedAt),
|
||||
...vendors.map((vendor) => vendor.updatedAt),
|
||||
...items.map((item) => item.updatedAt),
|
||||
...warehouses.map((warehouse) => warehouse.updatedAt),
|
||||
...purchaseOrders.map((order) => order.updatedAt),
|
||||
...workOrders.map((workOrder) => workOrder.updatedAt),
|
||||
...quotes.map((quote) => quote.updatedAt),
|
||||
...orders.map((order) => order.updatedAt),
|
||||
...shipments.map((shipment) => shipment.updatedAt),
|
||||
...projects.map((project) => project.updatedAt),
|
||||
]
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0;
|
||||
const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0;
|
||||
const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0;
|
||||
const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0;
|
||||
const planningItemCount = planningRollup?.summary.itemCount ?? 0;
|
||||
|
||||
const metricCards = [
|
||||
{
|
||||
label: "CRM Accounts",
|
||||
value: snapshot?.customers !== null ? `${customerCount}` : "No access",
|
||||
detail:
|
||||
snapshot?.customers !== null
|
||||
? `${vendorCount} vendors, ${resellerCount} resellers, ${activeCustomerCount} active`
|
||||
: "CRM metrics are permission-gated.",
|
||||
tone: "border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
label: "Accounts",
|
||||
value: snapshot?.customers !== null ? `${customerCount + vendorCount}` : "No access",
|
||||
secondary: snapshot?.customers !== null ? `${activeCustomerCount} active customers` : "",
|
||||
tone: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
label: "Inventory Footprint",
|
||||
label: "Inventory",
|
||||
value: snapshot?.items !== null ? `${itemCount}` : "No access",
|
||||
detail:
|
||||
snapshot?.items !== null
|
||||
? `${assemblyCount} buildable items across ${warehouseCount} warehouses`
|
||||
: "Inventory metrics are permission-gated.",
|
||||
tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
secondary: snapshot?.items !== null ? `${assemblyCount} buildable items` : "",
|
||||
tone: "bg-sky-500",
|
||||
},
|
||||
{
|
||||
label: "Purchasing Queue",
|
||||
value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access",
|
||||
detail:
|
||||
snapshot?.purchaseOrders !== null
|
||||
? `${issuedPurchaseOrderCount} issued/approved and ${formatCurrency(purchaseOrderValue)} committed`
|
||||
: "Purchasing metrics are permission-gated.",
|
||||
tone: "border-teal-400/30 bg-teal-500/12 text-teal-700 dark:text-teal-300",
|
||||
label: "Open Supply",
|
||||
value: snapshot?.purchaseOrders !== null || snapshot?.workOrders !== null ? `${openPurchaseOrderCount + activeWorkOrderCount}` : "No access",
|
||||
secondary: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount} PO | ${activeWorkOrderCount} WO` : "",
|
||||
tone: "bg-teal-500",
|
||||
},
|
||||
{
|
||||
label: "Manufacturing Load",
|
||||
value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access",
|
||||
detail:
|
||||
snapshot?.workOrders !== null
|
||||
? `${releasedWorkOrderCount} released and ${overdueWorkOrderCount} overdue`
|
||||
: "Manufacturing metrics are permission-gated.",
|
||||
tone: "border-indigo-400/30 bg-indigo-500/12 text-indigo-700 dark:text-indigo-300",
|
||||
},
|
||||
{
|
||||
label: "Commercial Value",
|
||||
label: "Commercial",
|
||||
value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access",
|
||||
detail:
|
||||
snapshot?.quotes !== null || snapshot?.orders !== null
|
||||
? `${quoteCount} quotes and ${orderCount} orders in the pipeline`
|
||||
: "Sales metrics are permission-gated.",
|
||||
tone: "border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
secondary: snapshot?.orders !== null ? `${orderCount} orders live` : "",
|
||||
tone: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
label: "Shipping Queue",
|
||||
value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access",
|
||||
detail:
|
||||
snapshot?.shipments !== null
|
||||
? `${inTransitCount} in transit, ${deliveredCount} delivered`
|
||||
: "Shipping metrics are permission-gated.",
|
||||
tone: "border-brand/30 bg-brand/10 text-brand",
|
||||
},
|
||||
{
|
||||
label: "Project Load",
|
||||
label: "Projects",
|
||||
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
|
||||
detail:
|
||||
snapshot?.projects !== null
|
||||
? `${atRiskProjectCount} at risk and ${overdueProjectCount} overdue`
|
||||
: "Project metrics are permission-gated.",
|
||||
tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||
},
|
||||
];
|
||||
|
||||
const modulePanels = [
|
||||
{
|
||||
title: "CRM",
|
||||
eyebrow: "Account Health",
|
||||
summary:
|
||||
snapshot?.customers !== null
|
||||
? "Live account counts, reseller coverage, and strategic-account concentration from the current CRM records."
|
||||
: "CRM read permission is required to surface customer and vendor metrics here.",
|
||||
metrics: [
|
||||
{ label: "Customers", value: snapshot?.customers !== null ? `${customerCount}` : "No access" },
|
||||
{ label: "Strategic", value: snapshot?.customers !== null ? `${strategicCustomerCount}` : "No access" },
|
||||
{ label: "Vendors", value: snapshot?.vendors !== null ? `${vendorCount}` : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open customers", to: "/crm/customers" },
|
||||
{ label: "Open vendors", to: "/crm/vendors" },
|
||||
],
|
||||
secondary: snapshot?.projects !== null ? `${atRiskProjectCount} at risk` : "",
|
||||
tone: "bg-violet-500",
|
||||
},
|
||||
{
|
||||
title: "Inventory",
|
||||
eyebrow: "Master + Stock",
|
||||
summary:
|
||||
snapshot?.items !== null
|
||||
? "Item master, BOM-capable parts, and warehouse footprint are now feeding the dashboard directly."
|
||||
: "Inventory read permission is required to surface item and warehouse metrics here.",
|
||||
metrics: [
|
||||
{ label: "Active items", value: snapshot?.items !== null ? `${activeItemCount}` : "No access" },
|
||||
{ label: "Assemblies", value: snapshot?.items !== null ? `${assemblyCount}` : "No access" },
|
||||
{ label: "Locations", value: snapshot?.warehouses !== null ? `${locationCount}` : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open inventory", to: "/inventory/items" },
|
||||
{ label: "Open warehouses", to: "/inventory/warehouses" },
|
||||
],
|
||||
label: "Readiness",
|
||||
value: planningRollup ? `${shortageItemCount}` : "No access",
|
||||
secondary: planningRollup ? `${totalUncoveredQuantity} units uncovered` : "",
|
||||
tone: "bg-rose-500",
|
||||
},
|
||||
{
|
||||
title: "Purchasing",
|
||||
eyebrow: "Inbound Supply",
|
||||
summary:
|
||||
snapshot?.purchaseOrders !== null
|
||||
? "Purchase orders, open commitments, and current inbound procurement load are now visible from the dashboard."
|
||||
: "Purchasing read permission is required to surface procurement metrics here.",
|
||||
metrics: [
|
||||
{ label: "Open POs", value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access" },
|
||||
{ label: "Issued", value: snapshot?.purchaseOrders !== null ? `${issuedPurchaseOrderCount}` : "No access" },
|
||||
{ label: "Committed", value: snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open purchase orders", to: "/purchasing/orders" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Manufacturing",
|
||||
eyebrow: "Execution Load",
|
||||
summary:
|
||||
snapshot?.workOrders !== null
|
||||
? "Work orders, released load, and overdue build pressure are now visible from the dashboard."
|
||||
: "Manufacturing read permission is required to surface work-order metrics here.",
|
||||
metrics: [
|
||||
{ label: "Open work", value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access" },
|
||||
{ label: "Released", value: snapshot?.workOrders !== null ? `${releasedWorkOrderCount}` : "No access" },
|
||||
{ label: "Overdue", value: snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open work orders", to: "/manufacturing/work-orders" },
|
||||
...(canWriteManufacturing ? [{ label: "New work order", to: "/manufacturing/work-orders/new" }] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Sales",
|
||||
eyebrow: "Revenue Flow",
|
||||
summary:
|
||||
snapshot?.quotes !== null || snapshot?.orders !== null
|
||||
? "Quotes and sales orders now contribute real commercial value, open-document counts, and pipeline visibility."
|
||||
: "Sales read permission is required to surface commercial metrics here.",
|
||||
metrics: [
|
||||
{ label: "Quote value", value: snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access" },
|
||||
{ label: "Order value", value: snapshot?.orders !== null ? formatCurrency(orderValue) : "No access" },
|
||||
{ label: "Draft quotes", value: snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open quotes", to: "/sales/quotes" },
|
||||
{ label: "Open sales orders", to: "/sales/orders" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Shipping",
|
||||
eyebrow: "Execution Queue",
|
||||
summary:
|
||||
snapshot?.shipments !== null
|
||||
? "Shipment records, in-transit volume, and completed deliveries are now visible from the landing page."
|
||||
: "Shipping read permission is required to surface shipment metrics here.",
|
||||
metrics: [
|
||||
{ label: "Open shipments", value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access" },
|
||||
{ label: "In transit", value: snapshot?.shipments !== null ? `${inTransitCount}` : "No access" },
|
||||
{ label: "Delivered", value: snapshot?.shipments !== null ? `${deliveredCount}` : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open shipments", to: "/shipping/shipments" },
|
||||
{ label: "Open packing flow", to: "/sales/orders" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
eyebrow: "Program Control",
|
||||
summary:
|
||||
snapshot?.projects !== null
|
||||
? "Project records now tie customers, commercial documents, shipment context, and delivery ownership into one operational surface."
|
||||
: "Project read permission is required to surface program metrics here.",
|
||||
metrics: [
|
||||
{ label: "Active", value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access" },
|
||||
{ label: "At risk", value: snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access" },
|
||||
{ label: "Overdue", value: snapshot?.projects !== null ? `${overdueProjectCount}` : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open projects", to: "/projects" },
|
||||
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Planning",
|
||||
eyebrow: "Schedule Visibility",
|
||||
summary: canReadPlanning
|
||||
? "Live gantt planning now pulls directly from active projects and open manufacturing work orders to show due-date pressure in one schedule view."
|
||||
: "Planning read permission is required to surface the live gantt schedule.",
|
||||
metrics: [
|
||||
{ label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" },
|
||||
{ label: "Overdue work", value: canReadPlanning ? `${overdueWorkOrderCount}` : "No access" },
|
||||
{ label: "Schedule links", value: canReadPlanning ? `${activeProjectCount + activeWorkOrderCount}` : "No access" },
|
||||
],
|
||||
links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [],
|
||||
},
|
||||
];
|
||||
|
||||
const futureModules = [
|
||||
"Stock transfers, allocations, and cycle counts",
|
||||
"Revision comparison and document restore tooling",
|
||||
"Audit trails, diagnostics, and system health checks",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="overflow-hidden rounded-[30px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur">
|
||||
<div className="grid gap-0 xl:grid-cols-[1.35fr_0.65fr]">
|
||||
<div className="relative overflow-hidden p-5 2xl:p-6">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(24,90,219,0.16),_transparent_44%),linear-gradient(135deg,rgba(255,255,255,0.06),transparent)]" />
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Dashboard</p>
|
||||
<h3 className="mt-2 max-w-3xl text-2xl font-extrabold text-text">Operational command surface for metrics, movement, and next actions.</h3>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2 text-right">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted">Last Refresh</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text">{snapshot ? formatDateTime(snapshot.refreshedAt) : "Waiting"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
|
||||
This landing page now reads directly from live CRM, inventory, purchasing, manufacturing, sales, shipping, and project data. It is
|
||||
intentionally modular so future planning, approvals, and audit slices can slot into the same command surface without a redesign.
|
||||
</p>
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Modules Live</p>
|
||||
<p className="mt-1 text-lg font-extrabold text-text">{accessibleModules}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Recent Activity</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text">{formatDateTime(lastActivityAt)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Loading State</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text">{isLoading ? "Refreshing data" : "Live snapshot loaded"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" to="/sales/orders">
|
||||
Open sales orders
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/shipping/shipments">
|
||||
Open shipments
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items">
|
||||
Open inventory
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/purchasing/orders">
|
||||
Open purchasing
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
|
||||
Open projects
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/manufacturing/work-orders">
|
||||
Open manufacturing
|
||||
</Link>
|
||||
{canReadPlanning ? (
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/planning/gantt">
|
||||
Open planning
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <div className="mt-4 rounded-2xl border border-amber-400/30 bg-amber-500/12 px-2 py-2 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l border-line/70 bg-page/40 p-5 2xl:p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Upgrade Path</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{futureModules.map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2 text-sm text-text">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-7">
|
||||
{metricCards.map((card) => (
|
||||
<article key={card.label} className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{card.label}</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-extrabold text-text">{card.value}</div>
|
||||
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${card.tone}`}>Live</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">{card.detail}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-7">
|
||||
{modulePanels.map((panel) => (
|
||||
<article key={panel.title} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{panel.eyebrow}</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">{panel.title}</h4>
|
||||
<p className="mt-3 text-sm leading-6 text-muted">{panel.summary}</p>
|
||||
<div className="mt-5 grid gap-2">
|
||||
{panel.metrics.map((metric) => (
|
||||
<div key={metric.label} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">{metric.label}</span>
|
||||
<span className="font-semibold text-text">{metric.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{panel.links.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<div className="page-stack">
|
||||
{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">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Obsolete items</span>
|
||||
<span className="font-semibold text-text">{snapshot?.items !== null ? `${obsoleteItemCount}` : "No access"}</span>
|
||||
{metricCards.map((card) => (
|
||||
<article key={card.label} className="surface-panel-tight">
|
||||
<p className="metric-kicker">{card.label}</p>
|
||||
<div className="mt-1.5 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div>
|
||||
<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-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted">Live</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Warehouse count</span>
|
||||
<span className="font-semibold text-text">{snapshot?.warehouses !== null ? `${warehouseCount}` : "No access"}</span>
|
||||
{card.secondary ? <div className="mt-1.5 text-xs text-muted">{card.secondary}</div> : null}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<DashboardCard eyebrow="COMMERCIAL">
|
||||
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Quotes</div>
|
||||
<div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div>
|
||||
<div className="mt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>Draft</span>
|
||||
<span>{draftQuoteCount}</span>
|
||||
</div>
|
||||
<ProgressBar value={draftQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-amber-500" />
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>Approved</span>
|
||||
<span>{approvedQuoteCount}</span>
|
||||
</div>
|
||||
<ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Stock locations</span>
|
||||
<span className="font-semibold text-text">{snapshot?.warehouses !== null ? `${locationCount}` : "No access"}</span>
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Orders</div>
|
||||
<div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</div>
|
||||
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted">Issued / approved</div>
|
||||
<div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted">Total orders</div>
|
||||
<div className="mt-1 text-lg font-semibold text-text">{orderCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5">
|
||||
<ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sales Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Commercial flow snapshot</h4>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Issued orders</span>
|
||||
<span className="font-semibold text-text">{snapshot?.orders !== null ? `${issuedOrderCount}` : "No access"}</span>
|
||||
</DashboardCard>
|
||||
<DashboardCard eyebrow="CRM">
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Active customers</span>
|
||||
<span className="font-semibold text-text">{snapshot?.customers !== null ? formatPercent(activeCustomerCount, Math.max(customerCount, 1)) : "No access"}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Draft quotes</span>
|
||||
<span className="font-semibold text-text">{snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access"}</span>
|
||||
<div className="grid gap-2.5 sm:grid-cols-3">
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Customers</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{customerCount}</div>
|
||||
</div>
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Resellers</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{resellerCount}</div>
|
||||
</div>
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Vendors</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{vendorCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Order backlog</span>
|
||||
<span className="font-semibold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</span>
|
||||
<div className="surface-panel-tight">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Strategic accounts</span>
|
||||
<span className="font-semibold text-text">{strategicCustomerCount}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={strategicCustomerCount} total={Math.max(customerCount, 1)} tone="bg-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Inbound supply and commitment load</h4>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Total purchase orders</span>
|
||||
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${purchaseOrderCount}` : "No access"}</span>
|
||||
</DashboardCard>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-3">
|
||||
<DashboardCard eyebrow="INVENTORY">
|
||||
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Item Mix</div>
|
||||
<div className="mt-2.5 space-y-2.5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>Active items</span>
|
||||
<span>{activeItemCount}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={activeItemCount} total={Math.max(itemCount, 1)} tone="bg-sky-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>Buildable items</span>
|
||||
<span>{assemblyCount}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={assemblyCount} total={Math.max(itemCount, 1)} tone="bg-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>Obsolete items</span>
|
||||
<span>{obsoleteItemCount}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={obsoleteItemCount} total={Math.max(itemCount, 1)} tone="bg-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Open queue</span>
|
||||
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Committed value</span>
|
||||
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access"}</span>
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Storage</div>
|
||||
<div className="mt-2.5 grid gap-2.5">
|
||||
<div className="surface-panel-tight bg-surface/80">
|
||||
<div className="text-xs text-muted">Warehouses</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div>
|
||||
</div>
|
||||
<div className="surface-panel-tight bg-surface/80">
|
||||
<div className="text-xs text-muted">Locations</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{locationCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</h4>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Total work orders</span>
|
||||
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${workOrderCount}` : "No access"}</span>
|
||||
</DashboardCard>
|
||||
<DashboardCard eyebrow="SUPPLY">
|
||||
<div className="mt-3 surface-panel-tight">
|
||||
<div className="metric-kicker">Open Workload</div>
|
||||
<div className="mt-2.5">
|
||||
<StackedBar
|
||||
segments={[
|
||||
{ value: openPurchaseOrderCount, tone: "bg-teal-500" },
|
||||
{ value: releasedWorkOrderCount, tone: "bg-indigo-500" },
|
||||
{ value: inProgressWorkOrderCount, tone: "bg-amber-500" },
|
||||
{ value: overdueWorkOrderCount, tone: "bg-rose-500" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Active queue</span>
|
||||
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Overdue</span>
|
||||
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access"}</span>
|
||||
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||
<div className="surface-panel-tight bg-surface/80">
|
||||
<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-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div>
|
||||
</div>
|
||||
<div className="surface-panel-tight bg-surface/80">
|
||||
<div className="text-xs text-muted">Active work orders</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{activeWorkOrderCount}</div>
|
||||
<div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div>
|
||||
</div>
|
||||
<div className="surface-panel-tight bg-surface/80">
|
||||
<div className="text-xs text-muted">Issued / approved POs</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div>
|
||||
</div>
|
||||
<div className="surface-panel-tight bg-surface/80">
|
||||
<div className="text-xs text-muted">Released WOs</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Program status and delivery pressure</h4>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Total projects</span>
|
||||
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${projectCount}` : "No access"}</span>
|
||||
</DashboardCard>
|
||||
<DashboardCard eyebrow="READINESS">
|
||||
<div className="mt-3 space-y-2.5">
|
||||
<div className="surface-panel-tight">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Shortage items</span>
|
||||
<span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">At risk</span>
|
||||
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access"}</span>
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Build Vs Buy</div>
|
||||
<div className="mt-2.5">
|
||||
<StackedBar
|
||||
segments={[
|
||||
{ value: buildRecommendationCount, tone: "bg-indigo-500" },
|
||||
{ value: buyRecommendationCount, tone: "bg-teal-500" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted">Build recommendations</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buildRecommendationCount : "No access"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted">Buy recommendations</div>
|
||||
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buyRecommendationCount : "No access"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Overdue</span>
|
||||
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${overdueProjectCount}` : "No access"}</span>
|
||||
<div className="surface-panel-tight">
|
||||
<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>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Execution and delivery status</h4>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Total shipments</span>
|
||||
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${shipmentCount}` : "No access"}</span>
|
||||
</DashboardCard>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<DashboardCard eyebrow="PROGRAMS">
|
||||
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Projects</div>
|
||||
<div className="mt-2.5">
|
||||
<StackedBar
|
||||
segments={[
|
||||
{ value: activeProjectCount, tone: "bg-violet-500" },
|
||||
{ value: atRiskProjectCount, tone: "bg-amber-500" },
|
||||
{ value: overdueProjectCount, tone: "bg-rose-500" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted">Active</div>
|
||||
<div className="mt-1 font-semibold text-text">{activeProjectCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted">At risk</div>
|
||||
<div className="mt-1 font-semibold text-text">{atRiskProjectCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted">Overdue</div>
|
||||
<div className="mt-1 font-semibold text-text">{overdueProjectCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Open queue</span>
|
||||
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Delivered</span>
|
||||
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${deliveredCount}` : "No access"}</span>
|
||||
<div className="surface-panel-tight">
|
||||
<div className="metric-kicker">Shipping</div>
|
||||
<div className="mt-2.5">
|
||||
<StackedBar
|
||||
segments={[
|
||||
{ value: activeShipmentCount, tone: "bg-brand" },
|
||||
{ value: inTransitCount, tone: "bg-sky-500" },
|
||||
{ value: deliveredCount, tone: "bg-emerald-500" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted">Open</div>
|
||||
<div className="mt-1 font-semibold text-text">{activeShipmentCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted">In transit</div>
|
||||
<div className="mt-1 font-semibold text-text">{inTransitCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted">Delivered</div>
|
||||
<div className="mt-1 font-semibold text-text">{deliveredCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</DashboardCard>
|
||||
<DashboardCard eyebrow="OPERATIONS">
|
||||
<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: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" },
|
||||
{ label: "Sales orders", value: orderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-amber-500" },
|
||||
{ label: "Purchase orders", value: purchaseOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-teal-500" },
|
||||
{ label: "Work orders", value: workOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-indigo-500" },
|
||||
{ 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" },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="surface-panel-tight">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted">{row.label}</span>
|
||||
<span className="font-semibold text-text">{row.value}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={row.value} total={row.total} tone={row.tone} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{snapshot ? <div className="mt-3 text-xs text-muted">REFRESHED {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null}
|
||||
</DashboardCard>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
469
client/src/modules/finance/FinancePage.tsx
Normal file
469
client/src/modules/finance/FinancePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +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 { 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 [status, setStatus] = useState("Loading live planning timeline...");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getPlanningTimeline(token)
|
||||
.then((data) => {
|
||||
setTimeline(data);
|
||||
setStatus("Planning timeline loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const tasks = timeline?.tasks ?? [];
|
||||
const links = timeline?.links ?? [];
|
||||
const summary = timeline?.summary;
|
||||
const exceptions = timeline?.exceptions ?? [];
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-text">Live Project + Manufacturing Gantt</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||
The planning surface now reads directly from active projects and open work orders so schedule pressure, due-date risk, and standalone manufacturing load are visible in one place.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3 text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Status</div>
|
||||
<div className="mt-2 font-semibold text-text">{status}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="grid gap-3 xl:grid-cols-6">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Projects</p>
|
||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeProjects ?? 0}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">At Risk</p>
|
||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.atRiskProjects ?? 0}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Projects</p>
|
||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueProjects ?? 0}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
|
||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeWorkOrders ?? 0}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work</p>
|
||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueWorkOrders ?? 0}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p>
|
||||
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div>
|
||||
</article>
|
||||
</section>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<div
|
||||
className={`gantt-theme overflow-hidden rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${
|
||||
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
{tasks.length} schedule rows
|
||||
</div>
|
||||
</div>
|
||||
<Gantt
|
||||
tasks={tasks.map((task: GanttTaskDto) => ({
|
||||
...task,
|
||||
start: new Date(task.start),
|
||||
end: new Date(task.end),
|
||||
parent: task.parentId ?? undefined,
|
||||
}))}
|
||||
links={links}
|
||||
/>
|
||||
</div>
|
||||
<aside className="space-y-3">
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning Exceptions</p>
|
||||
<p className="mt-2 text-sm text-muted">Priority schedule issues from live project due dates and manufacturing commitments.</p>
|
||||
{exceptions.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No planning exceptions are active.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
{exceptions.map((exception: PlanningExceptionDto) => (
|
||||
<Link key={exception.id} to={exception.detailHref} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
|
||||
<div className="mt-1 font-semibold text-text">{exception.title}</div>
|
||||
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
{exception.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
|
||||
<div className="mt-4 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>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import type {
|
||||
InventoryTransferInput,
|
||||
WarehouseLocationOptionDto,
|
||||
} from "@mrp/shared/dist/inventory/types.js";
|
||||
import type { FileAttachmentDto } from "@mrp/shared";
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { emptyInventoryTransactionInput, inventoryThumbnailOwnerType, inventoryTransactionOptions } from "./config";
|
||||
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
|
||||
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
||||
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
|
||||
@@ -48,8 +50,34 @@ export function InventoryDetailPage() {
|
||||
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
|
||||
const [isSavingReservation, setIsSavingReservation] = useState(false);
|
||||
const [status, setStatus] = useState("Loading inventory item...");
|
||||
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
|
||||
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "transaction" | "transfer" | "reservation";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||
|
||||
function replaceThumbnailPreview(nextUrl: string | null) {
|
||||
setThumbnailPreviewUrl((current) => {
|
||||
if (current) {
|
||||
window.URL.revokeObjectURL(current);
|
||||
}
|
||||
|
||||
return nextUrl;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !itemId) {
|
||||
@@ -92,6 +120,56 @@ export function InventoryDetailPage() {
|
||||
.catch(() => setLocationOptions([]));
|
||||
}, [itemId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (thumbnailPreviewUrl) {
|
||||
window.URL.revokeObjectURL(thumbnailPreviewUrl);
|
||||
}
|
||||
};
|
||||
}, [thumbnailPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !itemId || !canReadFiles) {
|
||||
setThumbnailAttachment(null);
|
||||
replaceThumbnailPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const activeToken: string = token;
|
||||
const activeItemId: string = itemId;
|
||||
|
||||
async function loadThumbnail() {
|
||||
const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId);
|
||||
const latestAttachment = attachments[0] ?? null;
|
||||
|
||||
if (!latestAttachment) {
|
||||
if (!cancelled) {
|
||||
setThumbnailAttachment(null);
|
||||
replaceThumbnailPreview(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await api.getFileContentBlob(activeToken, latestAttachment.id);
|
||||
if (!cancelled) {
|
||||
setThumbnailAttachment(latestAttachment);
|
||||
replaceThumbnailPreview(window.URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
|
||||
void loadThumbnail().catch(() => {
|
||||
if (!cancelled) {
|
||||
setThumbnailAttachment(null);
|
||||
replaceThumbnailPreview(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canReadFiles, itemId, token]);
|
||||
|
||||
function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) {
|
||||
setTransactionForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
@@ -100,8 +178,7 @@ export function InventoryDetailPage() {
|
||||
setTransferForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitTransaction() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
@@ -112,7 +189,7 @@ export function InventoryDetailPage() {
|
||||
try {
|
||||
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
|
||||
setItem(nextItem);
|
||||
setTransactionStatus("Stock transaction recorded.");
|
||||
setTransactionStatus("Stock transaction recorded. If this was posted in error, create an offsetting stock entry and verify the result in Recent Movements.");
|
||||
setTransactionForm((current) => ({
|
||||
...emptyInventoryTransactionInput,
|
||||
transactionType: current.transactionType,
|
||||
@@ -127,8 +204,7 @@ export function InventoryDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitTransfer() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +215,7 @@ export function InventoryDetailPage() {
|
||||
try {
|
||||
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
|
||||
setItem(nextItem);
|
||||
setTransferStatus("Transfer recorded.");
|
||||
setTransferStatus("Transfer recorded. Review stock balances on both locations and post a return transfer if this movement was entered incorrectly.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
|
||||
setTransferStatus(message);
|
||||
@@ -148,8 +224,7 @@ export function InventoryDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitReservation() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
@@ -160,7 +235,7 @@ export function InventoryDetailPage() {
|
||||
try {
|
||||
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
|
||||
setItem(nextItem);
|
||||
setReservationStatus("Reservation recorded.");
|
||||
setReservationStatus("Reservation recorded. Verify available stock and add a compensating reservation change if this demand hold was entered incorrectly.");
|
||||
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
|
||||
@@ -170,23 +245,81 @@ export function InventoryDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionLabel = inventoryTransactionOptions.find((option) => option.value === transactionForm.transactionType)?.label ?? "transaction";
|
||||
setPendingConfirmation({
|
||||
kind: "transaction",
|
||||
title: `Post ${transactionLabel.toLowerCase()}`,
|
||||
description: `Post a ${transactionLabel.toLowerCase()} of ${transactionForm.quantity} units for ${item.sku} at the selected stock location.`,
|
||||
impact:
|
||||
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||
? "This reduces available inventory immediately and affects downstream shortage and readiness calculations."
|
||||
: "This updates the stock ledger immediately and becomes part of the item transaction history.",
|
||||
recovery: "If this is incorrect, post an explicit offsetting transaction instead of editing history.",
|
||||
confirmLabel: `Post ${transactionLabel.toLowerCase()}`,
|
||||
confirmationLabel:
|
||||
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||
? "Type item SKU to confirm:"
|
||||
: undefined,
|
||||
confirmationValue:
|
||||
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||
? item.sku
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirmation({
|
||||
kind: "transfer",
|
||||
title: "Post inventory transfer",
|
||||
description: `Move ${transferForm.quantity} units of ${item.sku} between the selected source and destination locations.`,
|
||||
impact: "This creates paired stock movement entries and changes both source and destination availability immediately.",
|
||||
recovery: "If the move was entered incorrectly, post a reversing transfer back to the original location.",
|
||||
confirmLabel: "Post transfer",
|
||||
});
|
||||
}
|
||||
|
||||
function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirmation({
|
||||
kind: "reservation",
|
||||
title: "Create manual reservation",
|
||||
description: `Reserve ${reservationForm.quantity} units of ${item.sku}${reservationForm.locationId ? " at the selected location" : ""}.`,
|
||||
impact: "This reduces available quantity used by planning, purchasing, manufacturing, and readiness views.",
|
||||
recovery: "Add the correcting reservation entry if this hold should be reduced or removed.",
|
||||
confirmLabel: "Create reservation",
|
||||
});
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Detail</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{item.sku}</h3>
|
||||
<p className="section-kicker">INVENTORY DETAIL</p>
|
||||
<h3 className="module-title">{item.sku}</h3>
|
||||
<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} />
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</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 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">
|
||||
@@ -201,40 +334,40 @@ export function InventoryDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 xl:grid-cols-7">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<section className="grid gap-2 xl:grid-cols-7">
|
||||
<article className="surface-panel-tight">
|
||||
<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>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reserved</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Available</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Stock Locations</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transactions</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{item.transfers.length}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">ITEM DEFINITION</p>
|
||||
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
|
||||
@@ -252,6 +385,10 @@ export function InventoryDetailPage() {
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt>
|
||||
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Preferred vendor</dt>
|
||||
<dd className="mt-2 text-sm text-text">{item.preferredVendorName ?? "Not set"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
|
||||
<dd className="mt-2 text-sm text-text">
|
||||
@@ -260,12 +397,30 @@ export function InventoryDetailPage() {
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock By Location</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">THUMBNAIL</p>
|
||||
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
|
||||
{thumbnailPreviewUrl ? (
|
||||
<img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-square items-center justify-center px-4 text-center text-sm text-muted">
|
||||
No thumbnail image has been attached to this item.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">
|
||||
{thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">STOCK BY LOCATION</p>
|
||||
{item.stockBalances.length === 0 ? (
|
||||
<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) => (
|
||||
<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">
|
||||
@@ -289,9 +444,9 @@ export function InventoryDetailPage() {
|
||||
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
{canManage ? (
|
||||
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p>
|
||||
<div className="mt-5 grid gap-3">
|
||||
<form className="surface-panel" onSubmit={handleTransactionSubmit}>
|
||||
<p className="section-kicker">STOCK TRANSACTIONS</p>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
|
||||
@@ -330,7 +485,7 @@ export function InventoryDetailPage() {
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("notes", event.target.value)} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||
<span className="text-sm text-muted">{transactionStatus}</span>
|
||||
@@ -341,16 +496,16 @@ export function InventoryDetailPage() {
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Movements</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">RECENT MOVEMENTS</p>
|
||||
{item.recentTransactions.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No stock transactions have been recorded for this item yet.
|
||||
<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 recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.recentTransactions.map((transaction) => (
|
||||
<article key={transaction.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<article key={transaction.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -380,9 +535,9 @@ export function InventoryDetailPage() {
|
||||
|
||||
{canManage ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p>
|
||||
<div className="mt-5 grid gap-3">
|
||||
<form className="surface-panel" onSubmit={handleTransferSubmit}>
|
||||
<p className="section-kicker">INVENTORY TRANSFER</p>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="block">
|
||||
<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" />
|
||||
@@ -423,7 +578,7 @@ export function InventoryDetailPage() {
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={transferForm.notes} onChange={(event) => updateTransferField("notes", event.target.value)} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<textarea value={transferForm.notes} onChange={(event) => updateTransferField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||
<span className="text-sm text-muted">{transferStatus}</span>
|
||||
@@ -433,9 +588,9 @@ export function InventoryDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p>
|
||||
<div className="mt-5 grid gap-3">
|
||||
<form className="surface-panel" onSubmit={handleReservationSubmit}>
|
||||
<p className="section-kicker">MANUAL RESERVATION</p>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="block">
|
||||
<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" />
|
||||
@@ -460,7 +615,7 @@ export function InventoryDetailPage() {
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||
<span className="text-sm text-muted">{reservationStatus}</span>
|
||||
@@ -474,16 +629,16 @@ export function InventoryDetailPage() {
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Reservations</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">RESERVATIONS</p>
|
||||
{item.reservations.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No reservations have been recorded for this item.
|
||||
<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 recorded.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.reservations.map((reservation) => (
|
||||
<article key={reservation.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<article key={reservation.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{reservation.quantity} reserved</div>
|
||||
@@ -500,16 +655,16 @@ export function InventoryDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Transfers</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">TRANSFERS</p>
|
||||
{item.transfers.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No transfers have been recorded for this item.
|
||||
<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 recorded.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.transfers.map((transfer) => (
|
||||
<article key={transfer.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<article key={transfer.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-text">{transfer.quantity} moved</div>
|
||||
<div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div>
|
||||
@@ -526,6 +681,42 @@ export function InventoryDetailPage() {
|
||||
</section>
|
||||
|
||||
<InventoryAttachmentsPanel itemId={item.id} />
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm inventory action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={
|
||||
(pendingConfirmation?.kind === "transaction" && isSavingTransaction) ||
|
||||
(pendingConfirmation?.kind === "transfer" && isSavingTransfer) ||
|
||||
(pendingConfirmation?.kind === "reservation" && isSavingReservation)
|
||||
}
|
||||
onClose={() => {
|
||||
if (!isSavingTransaction && !isSavingTransfer && !isSavingReservation) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "transaction") {
|
||||
await submitTransaction();
|
||||
} else if (pendingConfirmation.kind === "transfer") {
|
||||
await submitTransfer();
|
||||
} else {
|
||||
await submitReservation();
|
||||
}
|
||||
|
||||
setPendingConfirmation(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
import type { FileAttachmentDto, PurchaseVendorOptionDto } from "@mrp/shared";
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type {
|
||||
InventoryBomLineInput,
|
||||
InventoryItemInput,
|
||||
InventoryItemOperationInput,
|
||||
InventoryItemOptionDto,
|
||||
InventorySkuBuilderPreviewDto,
|
||||
InventorySkuFamilyDto,
|
||||
InventorySkuNodeDto,
|
||||
} from "@mrp/shared/dist/inventory/types.js";
|
||||
import type { ManufacturingStationDto } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config";
|
||||
import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryThumbnailOwnerType, inventoryTypeOptions, inventoryUnitOptions } from "./config";
|
||||
|
||||
interface InventoryFormPageProps {
|
||||
mode: "create" | "edit";
|
||||
@@ -13,15 +24,27 @@ interface InventoryFormPageProps {
|
||||
|
||||
export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const { itemId } = useParams();
|
||||
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
|
||||
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
|
||||
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
||||
const [vendorOptions, setVendorOptions] = useState<PurchaseVendorOptionDto[]>([]);
|
||||
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
|
||||
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
|
||||
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
|
||||
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
|
||||
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingRemoval, setPendingRemoval] = useState<{ kind: "operation" | "bom-line"; index: number } | null>(null);
|
||||
const [skuFamilies, setSkuFamilies] = useState<InventorySkuFamilyDto[]>([]);
|
||||
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
|
||||
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]);
|
||||
const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(null);
|
||||
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
|
||||
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
|
||||
const [pendingThumbnailFile, setPendingThumbnailFile] = useState<File | null>(null);
|
||||
const [removeThumbnailOnSave, setRemoveThumbnailOnSave] = useState(false);
|
||||
|
||||
function getComponentOption(componentItemId: string) {
|
||||
return componentOptions.find((option) => option.id === componentItemId) ?? null;
|
||||
@@ -31,6 +54,27 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
return getComponentOption(componentItemId)?.sku ?? "";
|
||||
}
|
||||
|
||||
function getSkuFamilyName(familyId: string | null) {
|
||||
if (!familyId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return skuFamilies.find((family) => family.id === familyId)?.name ?? "";
|
||||
}
|
||||
|
||||
function replaceThumbnailPreview(nextUrl: string | null) {
|
||||
setThumbnailPreviewUrl((current) => {
|
||||
if (current) {
|
||||
window.URL.revokeObjectURL(current);
|
||||
}
|
||||
|
||||
return nextUrl;
|
||||
});
|
||||
}
|
||||
|
||||
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
@@ -65,6 +109,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
.then((item) => {
|
||||
setForm({
|
||||
sku: item.sku,
|
||||
skuBuilder: item.skuBuilder ? { familyId: item.skuBuilder.familyId, nodeId: item.skuBuilder.nodeId } : null,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
@@ -72,6 +117,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
unitOfMeasure: item.unitOfMeasure,
|
||||
isSellable: item.isSellable,
|
||||
isPurchasable: item.isPurchasable,
|
||||
preferredVendorId: item.preferredVendorId,
|
||||
defaultCost: item.defaultCost,
|
||||
defaultPrice: item.defaultPrice,
|
||||
notes: item.notes,
|
||||
@@ -91,8 +137,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
notes: operation.notes,
|
||||
})),
|
||||
});
|
||||
setSelectedSkuNodeIds(item.skuBuilder?.nodePath.map((entry) => entry.id) ?? []);
|
||||
setSkuPreview(item.skuBuilder ? { ...item.skuBuilder, nextSequenceNumber: item.skuBuilder.sequenceNumber ?? 0, availableLevels: Math.max(0, 6 - item.skuBuilder.segments.length), hasChildren: false } : null);
|
||||
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
|
||||
setStatus("Inventory item loaded.");
|
||||
setVendorSearchTerm(item.preferredVendorName ?? "");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
|
||||
@@ -106,12 +155,163 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
}
|
||||
|
||||
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
|
||||
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
|
||||
api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([]));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (thumbnailPreviewUrl) {
|
||||
window.URL.revokeObjectURL(thumbnailPreviewUrl);
|
||||
}
|
||||
};
|
||||
}, [thumbnailPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !itemId || !canReadFiles) {
|
||||
setThumbnailAttachment(null);
|
||||
replaceThumbnailPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const activeToken: string = token;
|
||||
const activeItemId: string = itemId;
|
||||
|
||||
async function loadThumbnail() {
|
||||
const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId);
|
||||
const latestAttachment = attachments[0] ?? null;
|
||||
|
||||
if (!latestAttachment) {
|
||||
if (!cancelled) {
|
||||
setThumbnailAttachment(null);
|
||||
replaceThumbnailPreview(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await api.getFileContentBlob(activeToken, latestAttachment.id);
|
||||
if (!cancelled) {
|
||||
setThumbnailAttachment(latestAttachment);
|
||||
replaceThumbnailPreview(window.URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
|
||||
void loadThumbnail().catch(() => {
|
||||
if (!cancelled) {
|
||||
setThumbnailAttachment(null);
|
||||
replaceThumbnailPreview(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canReadFiles, itemId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
const familyId = form.skuBuilder?.familyId ?? null;
|
||||
if (!token || !familyId) {
|
||||
setSkuLevelOptions([]);
|
||||
setSelectedSkuNodeIds([]);
|
||||
setSkuPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const activeFamilyId: string = familyId;
|
||||
const activeToken: string = token;
|
||||
|
||||
async function loadSkuBuilderState() {
|
||||
const nextOptions: InventorySkuNodeDto[][] = [];
|
||||
let parentNodeId: string | null = null;
|
||||
const lineage = selectedSkuNodeIds.filter((value): value is string => Boolean(value));
|
||||
|
||||
for (let levelIndex = 0; levelIndex <= lineage.length; levelIndex += 1) {
|
||||
const options = await api.getInventorySkuNodes(activeToken, activeFamilyId, parentNodeId ?? undefined);
|
||||
if (!options.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
nextOptions.push(options);
|
||||
const nextSelectedNodeId = lineage[levelIndex];
|
||||
if (!nextSelectedNodeId || !options.some((option) => option.id === nextSelectedNodeId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
parentNodeId = nextSelectedNodeId;
|
||||
}
|
||||
|
||||
const leafNodeId = lineage.length > 0 ? lineage[lineage.length - 1] : null;
|
||||
const preview = await api.getInventorySkuPreview(activeToken, activeFamilyId, leafNodeId ?? undefined);
|
||||
if (!cancelled) {
|
||||
setSkuLevelOptions(nextOptions);
|
||||
setSkuPreview(preview);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
sku: preview.generatedSku,
|
||||
skuBuilder: current.skuBuilder ? { familyId: current.skuBuilder.familyId, nodeId: leafNodeId ?? null } : current.skuBuilder,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
void loadSkuBuilderState().catch(() => {
|
||||
if (!cancelled) {
|
||||
setSkuLevelOptions([]);
|
||||
setSkuPreview(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.skuBuilder?.familyId, selectedSkuNodeIds, token]);
|
||||
|
||||
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateSkuFamily(familyId: string) {
|
||||
if (!familyId) {
|
||||
setSelectedSkuNodeIds([]);
|
||||
setSkuLevelOptions([]);
|
||||
setSkuPreview(null);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
skuBuilder: null,
|
||||
sku: mode === "edit" && !current.skuBuilder ? current.sku : "",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSkuNodeIds([]);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
skuBuilder: {
|
||||
familyId,
|
||||
nodeId: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function updateSkuNode(levelIndex: number, nodeId: string) {
|
||||
setSelectedSkuNodeIds((current) => {
|
||||
const next = current.slice(0, levelIndex);
|
||||
if (nodeId) {
|
||||
next[levelIndex] = nodeId;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedVendorName(vendorId: string | null) {
|
||||
if (!vendorId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return vendorOptions.find((vendor) => vendor.id === vendorId)?.name ?? "";
|
||||
}
|
||||
|
||||
function updateBomLine(index: number, nextLine: InventoryBomLineInput) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
@@ -177,6 +377,52 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current));
|
||||
}
|
||||
|
||||
function handleThumbnailSelect(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingThumbnailFile(file);
|
||||
setRemoveThumbnailOnSave(false);
|
||||
replaceThumbnailPreview(window.URL.createObjectURL(file));
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
function clearThumbnailSelection() {
|
||||
setPendingThumbnailFile(null);
|
||||
setRemoveThumbnailOnSave(true);
|
||||
replaceThumbnailPreview(null);
|
||||
}
|
||||
|
||||
async function syncThumbnail(savedItemId: string) {
|
||||
if (!token || !canWriteFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (thumbnailAttachment && (removeThumbnailOnSave || pendingThumbnailFile)) {
|
||||
await api.deleteAttachment(token, thumbnailAttachment.id);
|
||||
}
|
||||
|
||||
if (pendingThumbnailFile) {
|
||||
const uploaded = await api.uploadFile(token, pendingThumbnailFile, inventoryThumbnailOwnerType, savedItemId);
|
||||
setThumbnailAttachment(uploaded);
|
||||
const blob = await api.getFileContentBlob(token, uploaded.id);
|
||||
replaceThumbnailPreview(window.URL.createObjectURL(blob));
|
||||
} else if (removeThumbnailOnSave) {
|
||||
setThumbnailAttachment(null);
|
||||
}
|
||||
|
||||
setPendingThumbnailFile(null);
|
||||
setRemoveThumbnailOnSave(false);
|
||||
}
|
||||
|
||||
const pendingRemovalDetail = pendingRemoval
|
||||
? pendingRemoval.kind === "operation"
|
||||
? { label: form.operations[pendingRemoval.index]?.stationId || "this routing operation", typeLabel: "routing operation" }
|
||||
: { label: getComponentSku(form.bomLines[pendingRemoval.index]?.componentItemId ?? "") || "this BOM line", typeLabel: "BOM line" }
|
||||
: null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
@@ -189,6 +435,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
try {
|
||||
const saved =
|
||||
mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form);
|
||||
await syncThumbnail(saved.id);
|
||||
navigate(`/inventory/items/${saved.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save inventory item.";
|
||||
@@ -197,35 +444,108 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function forceNavigate(path: string) {
|
||||
window.location.assign(path);
|
||||
}
|
||||
|
||||
function openSkuMaster() {
|
||||
forceNavigate("/inventory/sku-master");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
forceNavigate(mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{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>
|
||||
<p className="section-kicker">INVENTORY EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "NEW ITEM" : "EDIT ITEM"}</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSkuMaster}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||
>
|
||||
SKU master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel space-y-3">
|
||||
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">SKU</span>
|
||||
<input
|
||||
value={form.sku}
|
||||
onChange={(event) => updateField("sku", event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<div className="block 2xl:col-span-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">SKU BUILDER</span>
|
||||
<button type="button" onClick={openSkuMaster} className="text-xs font-semibold text-brand">
|
||||
Manage SKU tree
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
|
||||
<select
|
||||
value={form.skuBuilder?.familyId ?? ""}
|
||||
onChange={(event) => updateSkuFamily(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
<option value="">Select family</option>
|
||||
{skuFamilies.filter((family) => family.isActive).map((family) => (
|
||||
<option key={family.id} value={family.id}>
|
||||
{family.code} - {family.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{skuLevelOptions.length > 0 ? (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{skuLevelOptions.map((options, levelIndex) => (
|
||||
<label key={`sku-level-${levelIndex}`} className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level {levelIndex + 2}</span>
|
||||
<select
|
||||
value={selectedSkuNodeIds[levelIndex] ?? ""}
|
||||
onChange={(event) => updateSkuNode(levelIndex, event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
<option value="">Stop at this level</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.code} - {option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-line/70 bg-surface px-2 py-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Generated SKU</div>
|
||||
<div className="mt-2 text-lg font-bold text-text">{skuPreview?.generatedSku || form.sku || "Select a family to generate SKU"}</div>
|
||||
<div className="mt-2 text-xs text-muted">
|
||||
{skuPreview
|
||||
? `${skuPreview.familyCode} branch with ${skuPreview.sequenceCode}${String(skuPreview.nextSequenceNumber).padStart(4, "0")} next in sequence.`
|
||||
: form.skuBuilder?.familyId
|
||||
? `Building from ${getSkuFamilyName(form.skuBuilder.familyId)}.`
|
||||
: "SKU suffix is family-scoped and assigned by the server when the item is saved."}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
value={form.sku}
|
||||
readOnly
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none opacity-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block 2xl:col-span-2">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Item name</span>
|
||||
<input
|
||||
@@ -257,6 +577,45 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-[220px_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Thumbnail</span>
|
||||
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
|
||||
{thumbnailPreviewUrl ? (
|
||||
<img src={thumbnailPreviewUrl} alt="Inventory thumbnail preview" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="px-4 text-center text-xs text-muted">No thumbnail selected</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canWriteFiles ? (
|
||||
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
Choose image
|
||||
<input className="hidden" type="file" accept="image/*" onChange={handleThumbnailSelect} />
|
||||
</label>
|
||||
) : null}
|
||||
{(thumbnailPreviewUrl || thumbnailAttachment) && canWriteFiles ? (
|
||||
<button type="button" onClick={clearThumbnailSelection} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Thumbnail attachment</div>
|
||||
<div className="mt-2 text-sm text-muted">
|
||||
{pendingThumbnailFile
|
||||
? `${pendingThumbnailFile.name} will upload when you save this item.`
|
||||
: removeThumbnailOnSave
|
||||
? "Thumbnail removal is staged and will apply when you save."
|
||||
: thumbnailAttachment
|
||||
? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.`
|
||||
: "Attach a product image, render, or reference photo for this item."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Type</span>
|
||||
@@ -303,7 +662,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
<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">
|
||||
<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 className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||
<input
|
||||
@@ -311,17 +670,87 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
checked={form.isPurchasable}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block xl:max-w-xl">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Preferred vendor</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={vendorSearchTerm}
|
||||
onChange={(event) => {
|
||||
setVendorSearchTerm(event.target.value);
|
||||
updateField("preferredVendorId", null);
|
||||
setVendorPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setVendorPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setVendorPickerOpen(false);
|
||||
if (form.preferredVendorId) {
|
||||
setVendorSearchTerm(getSelectedVendorName(form.preferredVendorId));
|
||||
}
|
||||
}, 120);
|
||||
}}
|
||||
disabled={!form.isPurchasable}
|
||||
placeholder={form.isPurchasable ? "Search vendor" : "Enable purchasable to assign sourcing"}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
{vendorPickerOpen && form.isPurchasable ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("preferredVendorId", null);
|
||||
setVendorSearchTerm("");
|
||||
setVendorPickerOpen(false);
|
||||
}}
|
||||
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70"
|
||||
>
|
||||
<div className="font-semibold text-text">No preferred vendor</div>
|
||||
</button>
|
||||
{vendorOptions
|
||||
.filter((vendor) => {
|
||||
const query = vendorSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((vendor) => (
|
||||
<button
|
||||
key={vendor.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("preferredVendorId", vendor.id);
|
||||
setVendorSearchTerm(vendor.name);
|
||||
setVendorPickerOpen(false);
|
||||
}}
|
||||
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
|
||||
>
|
||||
<div className="font-semibold text-text">{vendor.name}</div>
|
||||
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">
|
||||
{form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Used as the default buy source."}
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(event) => updateField("description", event.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
@@ -330,30 +759,29 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
value={form.notes}
|
||||
onChange={(event) => updateField("notes", event.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Routing</p>
|
||||
<h4 className="mt-2 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>
|
||||
<p className="section-kicker">MANUFACTURING ROUTING</p>
|
||||
<h4 className="text-lg font-bold text-text">STATION AND TIME TEMPLATE</h4>
|
||||
</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">
|
||||
Add operation
|
||||
</button>
|
||||
</div>
|
||||
{form.operations.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
Add at least one station operation for this buildable item.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="mt-3 space-y-3">
|
||||
{form.operations.map((operation, index) => (
|
||||
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Station</span>
|
||||
@@ -387,7 +815,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
<input type="number" min={0} step={10} value={operation.position} onChange={(event) => updateOperation(index, { ...operation, position: Number(event.target.value) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={() => removeOperation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
<button type="button" onClick={() => setPendingRemoval({ kind: "operation", index })} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
@@ -402,12 +830,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Bill Of Materials</p>
|
||||
<h4 className="mt-2 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>
|
||||
<p className="section-kicker">BILL OF MATERIALS</p>
|
||||
<h4 className="text-lg font-bold text-text">COMPONENT LINES</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -418,13 +845,13 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
{form.bomLines.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No BOM lines added yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="mt-3 space-y-3">
|
||||
{form.bomLines.map((line, index) => (
|
||||
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
|
||||
@@ -534,7 +961,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeBomLine(index)}
|
||||
onClick={() => setPendingRemoval({ kind: "bom-line", index })}
|
||||
className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300"
|
||||
>
|
||||
Remove
|
||||
@@ -553,7 +980,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
))}
|
||||
</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>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -564,6 +991,32 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={pendingRemoval != null}
|
||||
title={pendingRemoval?.kind === "operation" ? "Remove routing operation" : "Remove BOM line"}
|
||||
description={
|
||||
pendingRemoval && pendingRemovalDetail
|
||||
? `Remove ${pendingRemovalDetail.label} from the item ${pendingRemovalDetail.typeLabel} draft.`
|
||||
: "Remove this draft row."
|
||||
}
|
||||
impact={
|
||||
pendingRemoval?.kind === "operation"
|
||||
? "The operation will no longer be copied into new work orders from this item."
|
||||
: "The component requirement will be removed from the BOM draft immediately."
|
||||
}
|
||||
recovery="Add the row back before saving if this change was accidental."
|
||||
confirmLabel={pendingRemoval?.kind === "operation" ? "Remove operation" : "Remove BOM line"}
|
||||
onClose={() => setPendingRemoval(null)}
|
||||
onConfirm={() => {
|
||||
if (pendingRemoval?.kind === "operation") {
|
||||
removeOperation(pendingRemoval.index);
|
||||
} else if (pendingRemoval?.kind === "bom-line") {
|
||||
removeBomLine(pendingRemoval.index);
|
||||
}
|
||||
setPendingRemoval(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,22 +41,24 @@ export function InventoryListPage() {
|
||||
}, [searchTerm, statusFilter, token, typeFilter]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">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>
|
||||
<p className="section-kicker">INVENTORY</p>
|
||||
<h3 className="module-title">ITEM MASTER</h3>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
New item
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to="/inventory/sku-master" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
SKU master
|
||||
</Link>
|
||||
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
New item
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
|
||||
<div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||
<input
|
||||
@@ -95,13 +97,13 @@ export function InventoryListPage() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<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 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
|
||||
No inventory items have been added yet.
|
||||
</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">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
@@ -109,7 +111,7 @@ export function InventoryListPage() {
|
||||
<th className="px-2 py-2">Type</th>
|
||||
<th className="px-2 py-2">Status</th>
|
||||
<th className="px-2 py-2">UOM</th>
|
||||
<th className="px-2 py-2">Flags</th>
|
||||
<th className="px-2 py-2">Qty</th>
|
||||
<th className="px-2 py-2">BOM</th>
|
||||
<th className="px-2 py-2">Updated</th>
|
||||
</tr>
|
||||
@@ -131,8 +133,8 @@ export function InventoryListPage() {
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{item.unitOfMeasure}</td>
|
||||
<td className="px-2 py-2 text-xs text-muted">
|
||||
<div>{item.isSellable ? "Sellable" : "Not sellable"}</div>
|
||||
<div>{item.isPurchasable ? "Purchasable" : "Not purchasable"}</div>
|
||||
<div>Total {item.onHandQuantity}</div>
|
||||
<div>Available {item.availableQuantity}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{item.bomLineCount} lines</td>
|
||||
<td className="px-2 py-2 text-muted">{new Date(item.updatedAt).toLocaleDateString()}</td>
|
||||
@@ -145,3 +147,4 @@ export function InventoryListPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
342
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
342
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { InventorySkuCatalogTreeDto, InventorySkuFamilyInput, InventorySkuNodeInput } from "@mrp/shared/dist/inventory/types.js";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
|
||||
const emptyFamilyForm: InventorySkuFamilyInput = {
|
||||
code: "",
|
||||
sequenceCode: "",
|
||||
name: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const emptyNodeForm: InventorySkuNodeInput = {
|
||||
familyId: "",
|
||||
parentNodeId: null,
|
||||
code: "",
|
||||
label: "",
|
||||
description: "",
|
||||
sortOrder: 10,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export function InventorySkuMasterPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [catalog, setCatalog] = useState<InventorySkuCatalogTreeDto>({ families: [], nodes: [] });
|
||||
const [familyForm, setFamilyForm] = useState<InventorySkuFamilyInput>(emptyFamilyForm);
|
||||
const [nodeForm, setNodeForm] = useState<InventorySkuNodeInput>(emptyNodeForm);
|
||||
const [selectedFamilyId, setSelectedFamilyId] = useState("");
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState("Loading SKU master...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getInventorySkuCatalog(token)
|
||||
.then((nextCatalog) => {
|
||||
setCatalog(nextCatalog);
|
||||
const firstFamilyId = nextCatalog.families[0]?.id ?? "";
|
||||
setSelectedFamilyId((current) => current || firstFamilyId);
|
||||
setExpandedNodeIds([]);
|
||||
setNodeForm((current) => ({
|
||||
...current,
|
||||
familyId: current.familyId || firstFamilyId,
|
||||
}));
|
||||
setStatus(`${nextCatalog.families.length} family branch(es) loaded.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to load SKU master.");
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const familyNodes = useMemo(
|
||||
() =>
|
||||
catalog.nodes
|
||||
.filter((node) => node.familyId === selectedFamilyId)
|
||||
.sort((left, right) => left.level - right.level || left.sortOrder - right.sortOrder || left.code.localeCompare(right.code)),
|
||||
[catalog.nodes, selectedFamilyId]
|
||||
);
|
||||
|
||||
const parentOptions = useMemo(
|
||||
() => familyNodes.filter((node) => node.level < 6),
|
||||
[familyNodes]
|
||||
);
|
||||
|
||||
function toggleNode(nodeId: string) {
|
||||
setExpandedNodeIds((current) => (current.includes(nodeId) ? current.filter((id) => id !== nodeId) : [...current, nodeId]));
|
||||
}
|
||||
|
||||
function renderNodes(parentNodeId: string | null, depth = 0): ReactNode {
|
||||
const branchNodes = familyNodes.filter((node) => node.parentNodeId === parentNodeId);
|
||||
if (!branchNodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return branchNodes.map((node) => {
|
||||
const isExpanded = expandedNodeIds.includes(node.id);
|
||||
|
||||
return (
|
||||
<div key={node.id} className="space-y-2">
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2" style={{ marginLeft: `${depth * 16}px` }}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{node.childCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleNode(node.id)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-xl border border-line/70 text-sm font-semibold text-text transition hover:bg-page/80"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.code}`}
|
||||
>
|
||||
{isExpanded ? "-" : "+"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-xl border border-dashed border-line/50 text-xs text-muted">
|
||||
o
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-text">
|
||||
{node.code} <span className="text-muted">- {node.label}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">Level {node.level} - {node.childCount} child branch(es)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNodeForm((current) => ({
|
||||
...current,
|
||||
familyId: selectedFamilyId,
|
||||
parentNodeId: node.id,
|
||||
}))
|
||||
}
|
||||
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text"
|
||||
>
|
||||
Add child
|
||||
</button>
|
||||
</div>
|
||||
{node.description ? <div className="mt-2 text-xs text-muted">{node.description}</div> : null}
|
||||
</div>
|
||||
{isExpanded ? renderNodes(node.id, depth + 1) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadCatalog() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCatalog = await api.getInventorySkuCatalog(token);
|
||||
setCatalog(nextCatalog);
|
||||
if (!selectedFamilyId && nextCatalog.families[0]) {
|
||||
setSelectedFamilyId(nextCatalog.families[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateFamily(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token || !canManage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await api.createInventorySkuFamily(token, familyForm);
|
||||
setFamilyForm(emptyFamilyForm);
|
||||
setSelectedFamilyId(created.id);
|
||||
setExpandedNodeIds([]);
|
||||
setNodeForm((current) => ({ ...current, familyId: created.id, parentNodeId: null }));
|
||||
await reloadCatalog();
|
||||
setStatus(`Created SKU family ${created.code}.`);
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU family.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateNode(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token || !canManage || !nodeForm.familyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await api.createInventorySkuNode(token, nodeForm);
|
||||
setExpandedNodeIds((current) => {
|
||||
if (!created.parentNodeId || current.includes(created.parentNodeId)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, created.parentNodeId];
|
||||
});
|
||||
setNodeForm((current) => ({
|
||||
...emptyNodeForm,
|
||||
familyId: current.familyId,
|
||||
parentNodeId: created.parentNodeId,
|
||||
}));
|
||||
await reloadCatalog();
|
||||
setStatus(`Created SKU branch ${created.code}.`);
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU branch.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">INVENTORY MASTER DATA</p>
|
||||
<h3 className="module-title">SKU MASTER BUILDER</h3>
|
||||
</div>
|
||||
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Back to items
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[0.9fr_1.5fr]">
|
||||
<div className="space-y-3">
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">FAMILIES</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{catalog.families.length === 0 ? (
|
||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-sm text-muted">No SKU families defined yet.</div>
|
||||
) : (
|
||||
catalog.families.map((family) => (
|
||||
<button
|
||||
key={family.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedFamilyId(family.id);
|
||||
setExpandedNodeIds([]);
|
||||
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
|
||||
}}
|
||||
className={`block w-full rounded-[18px] border px-2 py-2 text-left transition ${
|
||||
selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-text">
|
||||
{family.code} <span className="text-muted">({family.sequenceCode})</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">{family.name}</div>
|
||||
<div className="mt-2 text-xs text-muted">
|
||||
{family.childNodeCount} branch nodes - next {family.sequenceCode}
|
||||
{String(family.nextSequenceNumber).padStart(4, "0")}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canManage ? (
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">ADD FAMILY</p>
|
||||
<form className="mt-3 space-y-3" onSubmit={handleCreateFamily}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
|
||||
<input value={familyForm.code} onChange={(event) => setFamilyForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Suffix code</span>
|
||||
<input value={familyForm.sequenceCode} onChange={(event) => setFamilyForm((current) => ({ ...current, sequenceCode: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
|
||||
<input value={familyForm.name} onChange={(event) => setFamilyForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||
<textarea value={familyForm.description} onChange={(event) => setFamilyForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">Create family</button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">BRANCH TREE</p>
|
||||
<div className="mt-1 text-xs text-muted">{status}</div>
|
||||
</div>
|
||||
{selectedFamilyId ? (
|
||||
<div className="text-xs text-muted">Up to 6 total SKU levels.</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canManage ? (
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">ADD BRANCH NODE</p>
|
||||
<form className="mt-3 space-y-3" onSubmit={handleCreateNode}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
|
||||
<select value={nodeForm.familyId} onChange={(event) => setNodeForm((current) => ({ ...current, familyId: event.target.value, parentNodeId: null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
<option value="">Select family</option>
|
||||
{catalog.families.map((family) => (
|
||||
<option key={family.id} value={family.id}>{family.code} - {family.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Parent branch</span>
|
||||
<select value={nodeForm.parentNodeId ?? ""} onChange={(event) => setNodeForm((current) => ({ ...current, parentNodeId: event.target.value || null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
<option value="">Family root</option>
|
||||
{parentOptions.map((node) => (
|
||||
<option key={node.id} value={node.id}>L{node.level} - {node.code} - {node.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
||||
<input value={nodeForm.code} onChange={(event) => setNodeForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Label</span>
|
||||
<input value={nodeForm.label} onChange={(event) => setNodeForm((current) => ({ ...current, label: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||
<textarea value={nodeForm.description} onChange={(event) => setNodeForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sort</span>
|
||||
<input type="number" min={0} step={10} value={nodeForm.sortOrder} onChange={(event) => setNodeForm((current) => ({ ...current, sortOrder: Number(event.target.value) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" disabled={!nodeForm.familyId}>Create branch</button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -32,25 +32,25 @@ export function WarehouseDetailPage() {
|
||||
}, [token, warehouseId]);
|
||||
|
||||
if (!warehouse) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Detail</p>
|
||||
<h3 className="mt-2 text-2xl font-bold text-text">{warehouse.code}</h3>
|
||||
<p className="mt-1 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="section-kicker">WAREHOUSE DETAIL</p>
|
||||
<h3 className="module-title">{warehouse.code}</h3>
|
||||
<p className="text-sm text-text">{warehouse.name}</p>
|
||||
<p className="mt-2 text-xs text-muted">Updated {new Date(warehouse.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<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
|
||||
</Link>
|
||||
{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
|
||||
</Link>
|
||||
) : null}
|
||||
@@ -58,27 +58,26 @@ export function WarehouseDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||
<article className="surface-panel">
|
||||
<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>
|
||||
<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()}
|
||||
</div>
|
||||
</article>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Stock locations</h4>
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">LOCATIONS</p>
|
||||
{warehouse.locations.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No stock locations have been defined for this warehouse yet.
|
||||
</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) => (
|
||||
<article key={location.id} className="rounded-3xl border border-line/70 bg-page/60 px-2 py-2">
|
||||
<article key={location.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div className="text-sm font-semibold text-text">{location.code}</div>
|
||||
<div className="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>
|
||||
))}
|
||||
</div>
|
||||
@@ -88,3 +87,4 @@ export function WarehouseDetailPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WarehouseInput, WarehouseLocationInput } from "@mrp/shared/dist/in
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config";
|
||||
@@ -13,6 +14,7 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const [form, setForm] = useState<WarehouseInput>(emptyWarehouseInput);
|
||||
const [status, setStatus] = useState(mode === "create" ? "Create a new warehouse." : "Loading warehouse...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingLocationRemovalIndex, setPendingLocationRemovalIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !token || !warehouseId) {
|
||||
@@ -67,6 +69,8 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
}));
|
||||
}
|
||||
|
||||
const pendingLocationRemoval = pendingLocationRemovalIndex != null ? form.locations[pendingLocationRemovalIndex] : null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
@@ -88,12 +92,12 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Warehouse" : "Edit Warehouse"}</h3>
|
||||
<p className="section-kicker">WAREHOUSE EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "NEW WAREHOUSE" : "EDIT WAREHOUSE"}</h3>
|
||||
</div>
|
||||
<Link
|
||||
to={mode === "create" ? "/inventory/warehouses" : `/inventory/warehouses/${warehouseId}`}
|
||||
@@ -103,40 +107,39 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel space-y-3">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<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" />
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<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" />
|
||||
</label>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Internal stock locations</h4>
|
||||
<p className="section-kicker">LOCATIONS</p>
|
||||
</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">
|
||||
Add location
|
||||
</button>
|
||||
</div>
|
||||
{form.locations.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No locations added yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="mt-3 space-y-3">
|
||||
{form.locations.map((location: WarehouseLocationInput, index: number) => (
|
||||
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div className="grid gap-3 xl:grid-cols-[0.7fr_1fr_auto]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
||||
@@ -147,12 +150,12 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<input value={location.name} onChange={(event) => updateLocation(index, { ...location, name: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={() => removeLocation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
<button type="button" onClick={() => setPendingLocationRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
Remove
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
@@ -160,13 +163,29 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
))}
|
||||
</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>
|
||||
<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"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={pendingLocationRemoval != null}
|
||||
title="Remove warehouse location"
|
||||
description={pendingLocationRemoval ? `Remove location ${pendingLocationRemoval.code || pendingLocationRemoval.name || "from this warehouse draft"}.` : "Remove this location."}
|
||||
impact="The location will be removed from the warehouse edit form immediately."
|
||||
recovery="Add the location back before saving if it should remain part of this warehouse."
|
||||
confirmLabel="Remove location"
|
||||
onClose={() => setPendingLocationRemovalIndex(null)}
|
||||
onConfirm={() => {
|
||||
if (pendingLocationRemovalIndex != null) {
|
||||
removeLocation(pendingLocationRemovalIndex);
|
||||
}
|
||||
setPendingLocationRemovalIndex(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,11 @@ export function WarehousesPage() {
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Warehouses</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted">Physical warehouse records and their internal stock locations.</p>
|
||||
<p className="section-kicker">INVENTORY</p>
|
||||
<h3 className="module-title">WAREHOUSES</h3>
|
||||
</div>
|
||||
{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">
|
||||
@@ -44,13 +43,13 @@ export function WarehousesPage() {
|
||||
</Link>
|
||||
) : null}
|
||||
</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 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No warehouses have been added yet.
|
||||
</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">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
@@ -80,3 +79,4 @@ export function WarehousesPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export const emptyInventoryOperationInput: InventoryItemOperationInput = {
|
||||
|
||||
export const emptyInventoryItemInput: InventoryItemInput = {
|
||||
sku: "",
|
||||
skuBuilder: null,
|
||||
name: "",
|
||||
description: "",
|
||||
type: "PURCHASED",
|
||||
@@ -41,6 +42,7 @@ export const emptyInventoryItemInput: InventoryItemInput = {
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: true,
|
||||
isPurchasable: true,
|
||||
preferredVendorId: null,
|
||||
defaultCost: null,
|
||||
defaultPrice: null,
|
||||
notes: "",
|
||||
@@ -105,6 +107,7 @@ export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
|
||||
};
|
||||
|
||||
export const inventoryFileOwnerType = "inventory-item";
|
||||
export const inventoryThumbnailOwnerType = "inventory-item-thumbnail";
|
||||
|
||||
export const inventoryTypePalette: Record<InventoryItemType, string> = {
|
||||
PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
|
||||
386
client/src/modules/landing/LandingPage.tsx
Normal file
386
client/src/modules/landing/LandingPage.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type LandingVariant = "light" | "dark";
|
||||
|
||||
const heroMetrics = [
|
||||
{ label: "On-time release rate", value: "98.2%", detail: "across 146 active jobs this week" },
|
||||
{ label: "Planner response time", value: "14 min", detail: "from demand signal to released draft supply" },
|
||||
{ label: "Inventory exposure", value: "$1.4M", detail: "visible by warehouse, location, and reservation" },
|
||||
];
|
||||
|
||||
const moduleCards = [
|
||||
{
|
||||
eyebrow: "Commercial Control",
|
||||
title: "Quotes, orders, revisions, and approvals stay connected.",
|
||||
copy: "Customer commitments flow directly into execution without spreadsheet handoffs or revision blind spots.",
|
||||
bullets: ["Revision comparisons", "Approval checkpoints", "Project linkage"],
|
||||
},
|
||||
{
|
||||
eyebrow: "Production Visibility",
|
||||
title: "Planning, projects, and work orders share the same reality.",
|
||||
copy: "Material readiness, shortages, pegged supply, and station execution all stay visible from one operational surface.",
|
||||
bullets: ["Live readiness rollups", "Reservation automation", "Capacity-ready planning"],
|
||||
},
|
||||
{
|
||||
eyebrow: "Inventory Discipline",
|
||||
title: "SKU structure, stock control, and purchasing are built for actual manufacturing.",
|
||||
copy: "Family-based SKU generation, BOM-aware demand, transfer visibility, and vendor-backed replenishment keep the floor aligned.",
|
||||
bullets: ["Master SKU builder", "BOM explosion", "Preferred-vendor sourcing"],
|
||||
},
|
||||
];
|
||||
|
||||
const timeline = [
|
||||
{ time: "06:40", title: "Demand spike detected", detail: "Three approved sales orders added 28 assemblies to near-term demand." },
|
||||
{ time: "07:05", title: "Planner drafts supply", detail: "System nets stock, open POs, and open work orders before recommending build and buy actions." },
|
||||
{ time: "08:10", title: "Reservations applied", detail: "Available stock updates immediately across project, manufacturing, and purchasing views." },
|
||||
{ time: "09:25", title: "Shipment package released", detail: "Packing slip PDF and label artifacts are ready from the same order thread." },
|
||||
];
|
||||
|
||||
const proofCards = [
|
||||
{ label: "Modules live", value: "10", note: "CRM, inventory, sales, purchasing, shipping, projects, manufacturing, planning, admin, branding" },
|
||||
{ label: "Data domains unified", value: "1", note: "Single operational model instead of disconnected tools" },
|
||||
{ label: "Container footprint", value: "1", note: "Frontend, backend, SQLite, uploads, Puppeteer PDF pipeline" },
|
||||
];
|
||||
|
||||
const spotlightBoard = [
|
||||
{ title: "Revenue committed", value: "$842K", change: "+18% vs last month", tone: "brand" },
|
||||
{ title: "Shortage lines", value: "12", change: "-31% after planner actions", tone: "accent" },
|
||||
{ title: "Work orders ready", value: "27", change: "6 waiting on materials", tone: "neutral" },
|
||||
{ title: "Shipments due today", value: "9", change: "4 already packed", tone: "brand" },
|
||||
];
|
||||
|
||||
function getThemeVars(variant: LandingVariant): CSSProperties {
|
||||
const vars = variant === "dark"
|
||||
? {
|
||||
colorScheme: "dark",
|
||||
"--color-brand": "88 150 255",
|
||||
"--color-accent": "44 214 199",
|
||||
"--color-surface-brand": "18 30 52",
|
||||
"--color-surface": "10 20 38",
|
||||
"--color-page": "3 10 24",
|
||||
"--color-text": "236 241 255",
|
||||
"--color-muted": "150 168 194",
|
||||
"--color-line": "68 87 120",
|
||||
}
|
||||
: {
|
||||
colorScheme: "light",
|
||||
"--color-brand": "29 78 216",
|
||||
"--color-accent": "13 148 136",
|
||||
"--color-surface-brand": "244 247 251",
|
||||
"--color-surface": "244 247 251",
|
||||
"--color-page": "239 244 255",
|
||||
"--color-text": "15 23 42",
|
||||
"--color-muted": "92 111 139",
|
||||
"--color-line": "196 210 233",
|
||||
};
|
||||
|
||||
return vars as CSSProperties;
|
||||
}
|
||||
|
||||
function panelClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "border-line/70 bg-surface/90 shadow-[0_30px_80px_rgba(2,6,23,0.45)]"
|
||||
: "border-line/70 bg-surface/90 shadow-[0_30px_80px_rgba(15,23,42,0.12)]";
|
||||
}
|
||||
|
||||
function softPanelClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "border-line/70 bg-page/80 shadow-[0_20px_60px_rgba(2,6,23,0.34)]"
|
||||
: "border-line/70 bg-page/75 shadow-[0_20px_50px_rgba(15,23,42,0.1)]";
|
||||
}
|
||||
|
||||
function chipClass(isDark: boolean) {
|
||||
return isDark ? "border-line/70 bg-page/80" : "border-line/70 bg-surface/90";
|
||||
}
|
||||
|
||||
function LandingExperience({ variant }: { variant: LandingVariant }) {
|
||||
const isDark = variant === "dark";
|
||||
const themeVars = getThemeVars(variant);
|
||||
const variantLabel = isDark ? "Dark landing" : "Light landing";
|
||||
const altRoute = isDark ? "/landing" : "/darklanding";
|
||||
const altLabel = isDark ? "View light version" : "View dark version";
|
||||
|
||||
return (
|
||||
<div id="top" style={themeVars} className="min-h-screen bg-page text-text">
|
||||
<div className="relative overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isDark
|
||||
? "bg-[radial-gradient(circle_at_12%_10%,rgba(88,150,255,0.28),transparent_26%),radial-gradient(circle_at_82%_18%,rgba(44,214,199,0.2),transparent_24%),linear-gradient(180deg,rgba(255,255,255,0.04),transparent_42%)]"
|
||||
: "bg-[radial-gradient(circle_at_12%_10%,rgba(59,130,246,0.24),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(20,184,166,0.18),transparent_24%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.08)_42%,transparent_60%)]"
|
||||
}`}
|
||||
/>
|
||||
<div className={`absolute left-[-8%] top-20 h-80 w-80 rounded-full blur-3xl ${isDark ? "bg-sky-400/20" : "bg-blue-300/40"}`} />
|
||||
<div className={`absolute right-[-10%] top-32 h-[28rem] w-[28rem] rounded-full blur-3xl ${isDark ? "bg-teal-300/16" : "bg-cyan-200/50"}`} />
|
||||
<div className={`absolute left-[22%] top-[32rem] h-64 w-64 rounded-full blur-3xl ${isDark ? "bg-indigo-400/10" : "bg-violet-200/35"}`} />
|
||||
|
||||
<div className="relative mx-auto flex w-full max-w-7xl flex-col gap-12 px-6 pb-20 pt-8 lg:px-8">
|
||||
<header className={`flex flex-col gap-4 rounded-[28px] border px-5 py-4 xl:flex-row xl:items-center xl:justify-between ${panelClass(isDark)}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,rgb(var(--color-brand)),rgb(var(--color-accent)))] text-lg font-black tracking-[0.18em] text-white">
|
||||
CX
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.34em] text-muted">CODEXIUM</div>
|
||||
<div className="text-sm text-muted">Manufacturing resource planning without the ERP drag.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`rounded-2xl border px-4 py-2 text-sm font-semibold text-text ${chipClass(isDark)}`}>
|
||||
{variantLabel}
|
||||
</span>
|
||||
<Link to={altRoute} className={`rounded-2xl border px-4 py-2 text-sm font-semibold text-text transition hover:bg-page/70 ${chipClass(isDark)}`}>
|
||||
{altLabel}
|
||||
</Link>
|
||||
<Link to="/login" className={`rounded-2xl border px-4 py-2 text-sm font-semibold text-text transition hover:bg-page/70 ${chipClass(isDark)}`}>
|
||||
Open app
|
||||
</Link>
|
||||
<a
|
||||
href="#contact"
|
||||
className="rounded-2xl bg-[linear-gradient(135deg,rgb(var(--color-brand)),rgb(var(--color-accent)))] px-4 py-2 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(24,90,219,0.28)]"
|
||||
>
|
||||
Book a walkthrough
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-10 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<div>
|
||||
<div className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-muted ${chipClass(isDark)}`}>
|
||||
Built for discrete manufacturing teams
|
||||
</div>
|
||||
<h1 className="mt-6 max-w-4xl font-['Space_Grotesk','Manrope',sans-serif] text-5xl font-black leading-[0.95] tracking-[-0.05em] text-text sm:text-6xl xl:text-7xl">
|
||||
A sharper MRP for operators who need flow, not ERP theater.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted">
|
||||
CODEXIUM connects commercial demand, inventory truth, project execution, purchasing, shipping, and manufacturing control in one system designed to move work across the floor.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{heroMetrics.map((metric) => (
|
||||
<article key={metric.label} className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">{metric.label}</div>
|
||||
<div className="mt-3 text-3xl font-black tracking-[-0.04em] text-text">{metric.value}</div>
|
||||
<div className="mt-2 text-sm text-muted">{metric.detail}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className={`absolute -left-8 top-10 hidden h-20 w-20 rounded-[28px] border lg:block ${isDark ? "border-white/20 bg-white/10" : "border-white/40 bg-white/20"}`} />
|
||||
<div className={`rounded-[32px] border p-5 ${isDark ? "bg-[linear-gradient(160deg,rgba(15,23,42,0.82),rgba(15,23,42,0.48))] shadow-[0_35px_90px_rgba(2,6,23,0.55)]" : "bg-[linear-gradient(160deg,rgba(255,255,255,0.68),rgba(255,255,255,0.24))] shadow-[0_35px_90px_rgba(15,23,42,0.18)]"} ${isDark ? "border-line/70" : "border-line/70"}`}>
|
||||
<div className={`rounded-[28px] border p-5 ${softPanelClass(isDark)}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Generated executive board</div>
|
||||
<div className="mt-2 text-2xl font-black tracking-[-0.04em] text-text">This week in operations</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-emerald-400/35 bg-emerald-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300">
|
||||
Live-ready demo data
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{spotlightBoard.map((card) => (
|
||||
<article
|
||||
key={card.title}
|
||||
className={`rounded-[24px] border p-4 ${
|
||||
card.tone === "brand"
|
||||
? isDark
|
||||
? "border-[rgb(var(--color-brand)/0.25)] bg-[rgb(var(--color-brand)/0.08)]"
|
||||
: "border-[rgb(var(--color-brand)/0.25)] bg-[rgb(var(--color-brand)/0.08)]"
|
||||
: card.tone === "accent"
|
||||
? isDark
|
||||
? "border-[rgb(var(--color-accent)/0.25)] bg-[rgb(var(--color-accent)/0.08)]"
|
||||
: "border-[rgb(var(--color-accent)/0.25)] bg-[rgb(var(--color-accent)/0.08)]"
|
||||
: softPanelClass(isDark)
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-muted">{card.title}</div>
|
||||
<div className="mt-3 text-4xl font-black tracking-[-0.05em] text-text">{card.value}</div>
|
||||
<div className="mt-2 text-sm text-muted">{card.change}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<div className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-text">Project and manufacturing pulse</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-muted">Generated snapshot</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{[
|
||||
{ label: "Falcon enclosure program", progress: 86, status: "Ready for final assembly" },
|
||||
{ label: "Orion service kit launch", progress: 61, status: "Waiting on one purchased line" },
|
||||
{ label: "Atlas retrofit release", progress: 43, status: "Routing review in progress" },
|
||||
].map((row) => (
|
||||
<div key={row.label} className={`rounded-[20px] border p-3 ${isDark ? "border-line/70 bg-page/75" : "border-line/60 bg-page/75"}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="font-semibold text-text">{row.label}</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-muted">{row.progress}%</div>
|
||||
</div>
|
||||
<div className={`mt-3 h-2 overflow-hidden rounded-full ${isDark ? "bg-line/70" : "bg-line/70"}`}>
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,rgb(var(--color-brand)),rgb(var(--color-accent)))]"
|
||||
style={{ width: `${row.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted">{row.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
|
||||
<div className="text-sm font-semibold text-text">Supply signals</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{[
|
||||
{ sku: "AXL-4472", action: "Build", qty: "18 EA" },
|
||||
{ sku: "KIT-2208", action: "Buy", qty: "240 EA" },
|
||||
{ sku: "CAB-9031", action: "Transfer", qty: "64 EA" },
|
||||
].map((signal) => (
|
||||
<div key={signal.sku} className={`rounded-[18px] border px-3 py-3 ${isDark ? "border-line/70 bg-page/75" : "border-line/60 bg-page/75"}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{signal.sku}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-muted">{signal.action} recommendation</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text">{signal.qty}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`grid gap-4 rounded-[32px] border p-6 lg:grid-cols-3 ${panelClass(isDark)}`}>
|
||||
{proofCards.map((card) => (
|
||||
<article key={card.label} className={`rounded-[24px] border p-4 ${softPanelClass(isDark)}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">{card.label}</div>
|
||||
<div className="mt-3 text-5xl font-black tracking-[-0.06em] text-text">{card.value}</div>
|
||||
<div className="mt-2 text-sm text-muted">{card.note}</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-6 pb-24 lg:px-8">
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
{moduleCards.map((card) => (
|
||||
<article key={card.title} className={`rounded-[28px] border p-6 ${panelClass(isDark)}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">{card.eyebrow}</div>
|
||||
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-2xl font-bold tracking-[-0.04em] text-text">{card.title}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-muted">{card.copy}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{card.bullets.map((bullet) => (
|
||||
<span key={bullet} className={`rounded-full border px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-text ${chipClass(isDark)}`}>
|
||||
{bullet}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className={`grid gap-8 rounded-[32px] border p-6 lg:grid-cols-[0.9fr_1.1fr] ${panelClass(isDark)}`}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">How it moves</div>
|
||||
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-4xl font-black tracking-[-0.05em] text-text">
|
||||
One demand signal. One operating picture.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-base leading-8 text-muted">
|
||||
Most systems split quoting, planning, purchasing, inventory, shipping, and production into separate stories. CODEXIUM treats them as one chain, so each step inherits context instead of creating reconciliation work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{timeline.map((step) => (
|
||||
<article key={step.time} className={`grid gap-4 rounded-[24px] border p-4 md:grid-cols-[92px_1fr] md:items-start ${softPanelClass(isDark)}`}>
|
||||
<div className={`rounded-[18px] px-4 py-3 text-center ${isDark ? "bg-surface/80" : "bg-surface/90"}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Time</div>
|
||||
<div className="mt-1 text-2xl font-black tracking-[-0.04em] text-text">{step.time}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-text">{step.title}</div>
|
||||
<div className="mt-2 text-sm leading-7 text-muted">{step.detail}</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<article className={`rounded-[32px] border p-6 ${panelClass(isDark)}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Who this is for</div>
|
||||
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-4xl font-black tracking-[-0.05em] text-text">
|
||||
Teams graduating from spreadsheets, generic ERPs, or disconnected point tools.
|
||||
</h2>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{[
|
||||
"Contract manufacturers that need real-time supply readiness before promising dates.",
|
||||
"OEM operations teams that need projects, BOMs, purchasing, and work orders linked end to end.",
|
||||
"Growing production shops that want branding, PDFs, approvals, and auditability without enterprise bloat.",
|
||||
"Leaders who want a system operators will actually open all day, not just during month-end cleanup.",
|
||||
].map((statement) => (
|
||||
<div key={statement} className={`rounded-[22px] border p-4 text-sm leading-7 text-text ${softPanelClass(isDark)}`}>
|
||||
{statement}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="contact" className={`rounded-[32px] border p-6 ${panelClass(isDark)}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Next step</div>
|
||||
<h2 className="mt-4 font-['Space_Grotesk','Manrope',sans-serif] text-3xl font-black tracking-[-0.05em] text-text">
|
||||
Pitch the platform with a page that already looks production-grade.
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-muted">
|
||||
Use this route as a commercial front door, demo backdrop, or customer-facing preview while the core app remains focused on execution.
|
||||
</p>
|
||||
<div className={`mt-6 space-y-3 rounded-[24px] border p-4 text-sm text-muted ${softPanelClass(isDark)}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Public route</span>
|
||||
<span className="font-semibold text-text">{isDark ? "/darklanding" : "/landing"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Core positioning</span>
|
||||
<span className="font-semibold text-text">Modern manufacturing MRP</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Demo style</span>
|
||||
<span className="font-semibold text-text">Generated commercial data</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-2xl bg-[linear-gradient(135deg,rgb(var(--color-brand)),rgb(var(--color-accent)))] px-4 py-3 text-sm font-semibold text-white shadow-[0_18px_40px_rgba(24,90,219,0.28)]"
|
||||
>
|
||||
Enter the app
|
||||
</Link>
|
||||
<a href="#top" className={`rounded-2xl border px-4 py-3 text-sm font-semibold text-text transition hover:bg-page/70 ${chipClass(isDark)}`}>
|
||||
Back to top
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingPage() {
|
||||
return <LandingExperience variant="light" />;
|
||||
}
|
||||
|
||||
export function DarkLandingPage() {
|
||||
return <LandingExperience variant="dark" />;
|
||||
}
|
||||
@@ -10,6 +10,9 @@ const emptyStationInput: ManufacturingStationInput = {
|
||||
name: "",
|
||||
description: "",
|
||||
queueDays: 0,
|
||||
dailyCapacityMinutes: 480,
|
||||
parallelCapacity: 1,
|
||||
workingDays: [1, 2, 3, 4, 5],
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
@@ -17,6 +20,7 @@ export function ManufacturingPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
||||
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 [isSaving, setIsSaving] = useState(false);
|
||||
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||
@@ -29,6 +33,27 @@ export function ManufacturingPage() {
|
||||
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
|
||||
}, [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>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
@@ -36,12 +61,15 @@ export function ManufacturingPage() {
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving station...");
|
||||
setStatus(editingStationId ? "Updating station..." : "Saving station...");
|
||||
try {
|
||||
const station = await api.createManufacturingStation(token, form);
|
||||
setStations((current) => [...current, station].sort((left, right) => left.code.localeCompare(right.code)));
|
||||
setForm(emptyStationInput);
|
||||
setStatus("Station saved.");
|
||||
const station = editingStationId
|
||||
? await api.updateManufacturingStation(token, editingStationId, form)
|
||||
: await api.createManufacturingStation(token, form);
|
||||
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) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save station.";
|
||||
setStatus(message);
|
||||
@@ -51,27 +79,33 @@ export function ManufacturingPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="page-stack">
|
||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Stations</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">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>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">MANUFACTURING STATIONS</p>
|
||||
<h3 className="module-title">SCHEDULING ANCHORS</h3>
|
||||
{stations.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No stations defined yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{stations.map((station) => (
|
||||
<article key={station.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<article key={station.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{station.code} - {station.name}</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 className="text-right text-xs text-muted">
|
||||
<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>
|
||||
</div>
|
||||
@@ -81,24 +115,64 @@ export function ManufacturingPage() {
|
||||
)}
|
||||
</article>
|
||||
{canManage ? (
|
||||
<form onSubmit={handleSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">New Station</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<form onSubmit={handleSubmit} className="surface-panel">
|
||||
<p className="section-kicker">{editingStationId ? "EDIT STATION" : "NEW STATION"}</p>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<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" />
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
<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" />
|
||||
</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">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<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" />
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} />
|
||||
@@ -106,9 +180,16 @@ export function ManufacturingPage() {
|
||||
</label>
|
||||
<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>
|
||||
<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..." : "Create station"}
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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 ? (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>
|
||||
</form>
|
||||
@@ -118,3 +199,4 @@ export function ManufacturingPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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 { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
@@ -7,6 +18,7 @@ import { Link, useParams } from "react-router-dom";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
|
||||
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
|
||||
|
||||
@@ -15,12 +27,37 @@ export function WorkOrderDetailPage() {
|
||||
const { workOrderId } = useParams();
|
||||
const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null);
|
||||
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
|
||||
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
|
||||
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
|
||||
const [holdReasonDraft, setHoldReasonDraft] = useState("");
|
||||
const [status, setStatus] = useState("Loading work order...");
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
||||
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
|
||||
const [operationLaborForm, setOperationLaborForm] = useState<Record<string, WorkOrderOperationLaborEntryInput>>({});
|
||||
const [operationAssignmentForm, setOperationAssignmentForm] = useState<Record<string, WorkOrderOperationAssignmentInput>>({});
|
||||
const [operationTimerForm, setOperationTimerForm] = useState<Record<string, WorkOrderOperationTimerInput>>({});
|
||||
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
|
||||
const [executingOperationId, setExecutingOperationId] = useState<string | null>(null);
|
||||
const [postingLaborOperationId, setPostingLaborOperationId] = useState<string | null>(null);
|
||||
const [assigningOperationId, setAssigningOperationId] = useState<string | null>(null);
|
||||
const [timerOperationId, setTimerOperationId] = useState<string | null>(null);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "status" | "issue" | "completion";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
nextStatus?: WorkOrderStatus;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||
|
||||
@@ -41,6 +78,26 @@ export function WorkOrderDetailPage() {
|
||||
...emptyCompletionInput,
|
||||
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.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -49,6 +106,7 @@ export function WorkOrderDetailPage() {
|
||||
});
|
||||
|
||||
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
|
||||
api.getManufacturingUserOptions(token).then(setOperatorOptions).catch(() => setOperatorOptions([]));
|
||||
}, [token, workOrderId]);
|
||||
|
||||
const filteredLocationOptions = useMemo(
|
||||
@@ -56,7 +114,7 @@ export function WorkOrderDetailPage() {
|
||||
[issueForm.warehouseId, locationOptions]
|
||||
);
|
||||
|
||||
async function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||
async function applyStatusChange(nextStatus: WorkOrderStatus) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
@@ -64,9 +122,13 @@ export function WorkOrderDetailPage() {
|
||||
setIsUpdatingStatus(true);
|
||||
setStatus("Updating work-order status...");
|
||||
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);
|
||||
setStatus("Work-order status updated.");
|
||||
setHoldReasonDraft("");
|
||||
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
||||
setStatus(message);
|
||||
@@ -75,8 +137,7 @@ export function WorkOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitIssue() {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
@@ -91,7 +152,7 @@ export function WorkOrderDetailPage() {
|
||||
warehouseId: nextWorkOrder.warehouseId,
|
||||
locationId: nextWorkOrder.locationId,
|
||||
});
|
||||
setStatus("Material issue posted.");
|
||||
setStatus("Material issue posted. This consumed inventory immediately; post a correcting stock movement if the issue quantity was wrong.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
|
||||
setStatus(message);
|
||||
@@ -100,8 +161,7 @@ export function WorkOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function submitCompletion() {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +175,7 @@ export function WorkOrderDetailPage() {
|
||||
...emptyCompletionInput,
|
||||
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||
});
|
||||
setStatus("Completion posted.");
|
||||
setStatus("Completion posted. Finished-goods stock has been received; verify the remaining quantity and post a correcting transaction if this completion was overstated.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
|
||||
setStatus(message);
|
||||
@@ -124,34 +184,242 @@ export function WorkOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperationReschedule(operationId: string) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = operationScheduleForm[operationId];
|
||||
if (!payload?.plannedStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReschedulingOperationId(operationId);
|
||||
setStatus("Rebuilding operation schedule...");
|
||||
try {
|
||||
const nextWorkOrder = await api.updateWorkOrderOperationSchedule(token, workOrder.id, operationId, payload);
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setOperationScheduleForm(
|
||||
Object.fromEntries(
|
||||
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
|
||||
)
|
||||
);
|
||||
setStatus("Operation schedule updated with station calendar constraints.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to reschedule operation.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setReschedulingOperationId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperationExecution(operationId: string, action: WorkOrderOperationExecutionInput["action"]) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExecutingOperationId(operationId);
|
||||
setStatus("Updating operation execution...");
|
||||
try {
|
||||
const nextWorkOrder = await api.updateWorkOrderOperationExecution(token, workOrder.id, operationId, {
|
||||
action,
|
||||
notes: `${action} from work-order detail`,
|
||||
});
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setOperationScheduleForm(
|
||||
Object.fromEntries(
|
||||
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
|
||||
)
|
||||
);
|
||||
setStatus("Operation execution updated.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update operation execution.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setExecutingOperationId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperationLabor(operationId: string) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = operationLaborForm[operationId];
|
||||
if (!payload?.minutes) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPostingLaborOperationId(operationId);
|
||||
setStatus("Posting labor entry...");
|
||||
try {
|
||||
const nextWorkOrder = await api.recordWorkOrderOperationLabor(token, workOrder.id, operationId, payload);
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setOperationLaborForm((current) => ({
|
||||
...current,
|
||||
[operationId]: {
|
||||
minutes: Math.max(Math.round((nextWorkOrder.operations.find((operation) => operation.id === operationId)?.plannedMinutes ?? 60) / 4), 15),
|
||||
notes: "",
|
||||
},
|
||||
}));
|
||||
setStatus("Labor entry posted.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to post operation labor.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setPostingLaborOperationId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperationAssignment(operationId: string) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = operationAssignmentForm[operationId];
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAssigningOperationId(operationId);
|
||||
setStatus("Updating operator assignment...");
|
||||
try {
|
||||
const nextWorkOrder = await api.updateWorkOrderOperationAssignment(token, workOrder.id, operationId, payload);
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setStatus("Operator assignment updated.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update operator assignment.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setAssigningOperationId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperationTimer(operationId: string, action: WorkOrderOperationTimerInput["action"]) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = operationTimerForm[operationId] ?? { action, notes: "" };
|
||||
setTimerOperationId(operationId);
|
||||
setStatus(action === "START" ? "Starting timer..." : "Stopping timer...");
|
||||
try {
|
||||
const nextWorkOrder = await api.updateWorkOrderOperationTimer(token, workOrder.id, operationId, {
|
||||
action,
|
||||
notes: payload.notes,
|
||||
});
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setOperationTimerForm((current) => ({
|
||||
...current,
|
||||
[operationId]: {
|
||||
action: action === "START" ? "STOP" : "START",
|
||||
notes: "",
|
||||
},
|
||||
}));
|
||||
setStatus(action === "START" ? "Operation timer started." : "Operation timer stopped and labor posted.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update operation timer.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setTimerOperationId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||
if (!workOrder) {
|
||||
return;
|
||||
}
|
||||
const option = workOrderStatusOptions.find((entry) => entry.value === nextStatus);
|
||||
setPendingConfirmation({
|
||||
kind: "status",
|
||||
title: `Change status to ${option?.label ?? nextStatus}`,
|
||||
description: `Update work order ${workOrder.workOrderNumber} from ${workOrder.status} to ${nextStatus}.`,
|
||||
impact:
|
||||
nextStatus === "CANCELLED"
|
||||
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
||||
: nextStatus === "ON_HOLD"
|
||||
? "Putting a work order on hold pauses expected execution and should capture the exact blocker so planning and shop-floor review stay aligned."
|
||||
: nextStatus === "COMPLETE"
|
||||
? "Completing the work order signals execution closure and can change readiness views across the system."
|
||||
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
||||
recovery: "If this status was selected in error, set the work order back to the correct state immediately after review.",
|
||||
confirmLabel: `Set ${option?.label ?? nextStatus}`,
|
||||
confirmationLabel: nextStatus === "CANCELLED" ? "Type work-order number to confirm:" : undefined,
|
||||
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
||||
nextStatus,
|
||||
});
|
||||
setHoldReasonDraft(nextStatus === "ON_HOLD" ? workOrder.holdReason ?? "" : "");
|
||||
}
|
||||
|
||||
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!workOrder) {
|
||||
return;
|
||||
}
|
||||
const component = workOrder.materialRequirements.find((requirement) => requirement.componentItemId === issueForm.componentItemId);
|
||||
setPendingConfirmation({
|
||||
kind: "issue",
|
||||
title: "Post material issue",
|
||||
description: `Issue ${issueForm.quantity} units of ${component?.componentSku ?? "the selected component"} to work order ${workOrder.workOrderNumber}.`,
|
||||
impact: "This consumes component inventory immediately and updates work-order material history.",
|
||||
recovery: "If the wrong quantity was issued, post a correcting stock transaction and note the reason on the work order.",
|
||||
confirmLabel: "Post issue",
|
||||
confirmationLabel: "Type work-order number to confirm:",
|
||||
confirmationValue: workOrder.workOrderNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!workOrder) {
|
||||
return;
|
||||
}
|
||||
setPendingConfirmation({
|
||||
kind: "completion",
|
||||
title: "Post production completion",
|
||||
description: `Receive ${completionForm.quantity} finished units into ${workOrder.warehouseCode} / ${workOrder.locationCode}.`,
|
||||
impact: "This increases finished-goods inventory immediately and advances the execution history for this work order.",
|
||||
recovery: "If the completion quantity is wrong, post the correcting inventory movement and verify the work-order remaining quantity.",
|
||||
confirmLabel: "Post completion",
|
||||
confirmationLabel: completionForm.quantity >= workOrder.dueQuantity ? "Type work-order number to confirm:" : undefined,
|
||||
confirmationValue: completionForm.quantity >= workOrder.dueQuantity ? workOrder.workOrderNumber : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!workOrder) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Order</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3>
|
||||
<p className="section-kicker">WORK ORDER</p>
|
||||
<h3 className="module-title">{workOrder.workOrderNumber}</h3>
|
||||
<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 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>
|
||||
{workOrder.projectId ? <Link to={`/projects/${workOrder.projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open project</Link> : null}
|
||||
{workOrder.salesOrderId ? <Link to={`/sales/orders/${workOrder.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={`/inventory/items/${workOrder.itemId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open item</Link>
|
||||
{canManage ? <Link to={`/manufacturing/work-orders/${workOrder.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit work order</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||
<p className="mt-2 text-sm text-muted">Release, hold, or close administrative status from the work-order record.</p>
|
||||
<p className="section-kicker">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{workOrderStatusOptions.map((option) => (
|
||||
@@ -163,43 +431,49 @@ export function WorkOrderDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-3 xl:grid-cols-6">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||
<section className="grid gap-2 xl:grid-cols-6">
|
||||
<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="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="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="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="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="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="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>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Execution Context</p>
|
||||
<dl className="mt-5 grid gap-3">
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">EXECUTION CONTEXT</p>
|
||||
<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">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">Project customer</dt><dd className="mt-1 text-sm text-text">{workOrder.projectCustomerName || "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>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Instructions</p>
|
||||
<article className="surface-panel">
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Operation Plan</p>
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">OPERATION PLAN</p>
|
||||
{workOrder.operations.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
|
||||
) : (
|
||||
<div className="mt-5 overflow-hidden rounded-3xl border border-line/70">
|
||||
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/70">
|
||||
<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">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">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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
@@ -210,9 +484,160 @@ export function WorkOrderDetailPage() {
|
||||
<div className="font-semibold text-text">{operation.stationCode}</div>
|
||||
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
|
||||
</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.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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -222,11 +647,11 @@ export function WorkOrderDetailPage() {
|
||||
</section>
|
||||
{canManage ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<form onSubmit={handleIssueSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Issue</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<form onSubmit={handleIssueSubmit} className="surface-panel">
|
||||
<p className="section-kicker">MATERIAL ISSUE</p>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<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">
|
||||
<option value="">Select component</option>
|
||||
{workOrder.materialRequirements.map((requirement) => (
|
||||
@@ -236,7 +661,7 @@ export function WorkOrderDetailPage() {
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<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">
|
||||
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
|
||||
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
|
||||
@@ -244,7 +669,7 @@ export function WorkOrderDetailPage() {
|
||||
</select>
|
||||
</label>
|
||||
<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">
|
||||
<option value="">Select location</option>
|
||||
{filteredLocationOptions.map((option) => (
|
||||
@@ -253,29 +678,29 @@ export function WorkOrderDetailPage() {
|
||||
</select>
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<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" />
|
||||
</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">
|
||||
{isPostingIssue ? "Posting issue..." : "Post material issue"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form onSubmit={handleCompletionSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Production Completion</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<form onSubmit={handleCompletionSubmit} className="surface-panel">
|
||||
<p className="section-kicker">PRODUCTION COMPLETION</p>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<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" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<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" />
|
||||
</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>
|
||||
<button type="submit" disabled={isPostingCompletion} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||
@@ -285,12 +710,12 @@ export function WorkOrderDetailPage() {
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Requirements</p>
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">MATERIAL REQUIREMENTS</p>
|
||||
{workOrder.materialRequirements.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">This build item does not currently have BOM material requirements.</div>
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This build item does not currently have BOM material requirements.</div>
|
||||
) : (
|
||||
<div className="mt-5 overflow-hidden rounded-3xl border border-line/70">
|
||||
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/70">
|
||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
@@ -299,6 +724,8 @@ export function WorkOrderDetailPage() {
|
||||
<th className="px-3 py-3">Required</th>
|
||||
<th className="px-3 py-3">Issued</th>
|
||||
<th className="px-3 py-3">Remaining</th>
|
||||
<th className="px-3 py-3">Available</th>
|
||||
<th className="px-3 py-3">Shortage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
@@ -309,6 +736,8 @@ export function WorkOrderDetailPage() {
|
||||
<td className="px-3 py-3 text-text">{requirement.requiredQuantity}</td>
|
||||
<td className="px-3 py-3 text-text">{requirement.issuedQuantity}</td>
|
||||
<td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td>
|
||||
<td className="px-3 py-3 text-text">{requirement.availableQuantity}</td>
|
||||
<td className="px-3 py-3 text-text">{requirement.shortageQuantity}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -317,14 +746,14 @@ export function WorkOrderDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Issue History</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">ISSUE HISTORY</p>
|
||||
{workOrder.materialIssues.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No material issues have been posted yet.</div>
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No material issues have been posted yet.</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{workOrder.materialIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3">
|
||||
<div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
|
||||
@@ -339,14 +768,14 @@ export function WorkOrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Completion History</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">COMPLETION HISTORY</p>
|
||||
{workOrder.completions.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No production completions have been posted yet.</div>
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No production completions have been posted yet.</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{workOrder.completions.map((completion) => (
|
||||
<div key={completion.id} className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3">
|
||||
<div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="font-semibold text-text">{completion.quantity} completed</div>
|
||||
<div className="text-xs text-muted">{completion.createdByName}</div>
|
||||
@@ -368,6 +797,48 @@ export function WorkOrderDetailPage() {
|
||||
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
|
||||
/>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm manufacturing action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
extraFieldLabel={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Hold reason" : undefined}
|
||||
extraFieldPlaceholder={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Explain the blocker forcing this work order onto hold." : undefined}
|
||||
extraFieldValue={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? holdReasonDraft : undefined}
|
||||
extraFieldRequired={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
|
||||
extraFieldMultiline={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
|
||||
onExtraFieldChange={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? setHoldReasonDraft : undefined}
|
||||
isConfirming={
|
||||
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
||||
(pendingConfirmation?.kind === "completion" && isPostingCompletion)
|
||||
}
|
||||
onClose={() => {
|
||||
if (!isUpdatingStatus && !isPostingIssue && !isPostingCompletion) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||
} else if (pendingConfirmation.kind === "issue") {
|
||||
await submitIssue();
|
||||
} else if (pendingConfirmation.kind === "completion") {
|
||||
await submitCompletion();
|
||||
}
|
||||
|
||||
setPendingConfirmation(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from "@mrp/shared";
|
||||
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
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 { api, ApiError } from "../../lib/api";
|
||||
@@ -17,6 +17,13 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const { workOrderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const seededProjectId = searchParams.get("projectId");
|
||||
const seededItemId = searchParams.get("itemId");
|
||||
const seededSalesOrderId = searchParams.get("salesOrderId");
|
||||
const seededSalesOrderLineId = searchParams.get("salesOrderLineId");
|
||||
const seededQuantity = searchParams.get("quantity");
|
||||
const seededStatus = searchParams.get("status");
|
||||
const seededDueDate = searchParams.get("dueDate");
|
||||
const seededNotes = searchParams.get("notes");
|
||||
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
|
||||
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
|
||||
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
|
||||
@@ -33,7 +40,25 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
|
||||
api.getManufacturingItemOptions(token).then((options) => {
|
||||
setItemOptions(options);
|
||||
if (mode === "create" && seededItemId) {
|
||||
const seededItem = options.find((option) => option.id === seededItemId);
|
||||
if (seededItem) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
itemId: seededItem.id,
|
||||
salesOrderId: seededSalesOrderId || current.salesOrderId,
|
||||
salesOrderLineId: seededSalesOrderLineId || current.salesOrderLineId,
|
||||
quantity: seededQuantity ? Number.parseInt(seededQuantity, 10) || current.quantity : current.quantity,
|
||||
status: seededStatus && workOrderStatusOptions.some((option) => option.value === seededStatus) ? (seededStatus as WorkOrderInput["status"]) : current.status,
|
||||
dueDate: seededDueDate || current.dueDate,
|
||||
notes: seededNotes || current.notes,
|
||||
}));
|
||||
setItemSearchTerm(`${seededItem.sku} - ${seededItem.name}`);
|
||||
}
|
||||
}
|
||||
}).catch(() => setItemOptions([]));
|
||||
api.getManufacturingProjectOptions(token).then((options) => {
|
||||
setProjectOptions(options);
|
||||
if (mode === "create" && seededProjectId) {
|
||||
@@ -45,7 +70,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
}
|
||||
}).catch(() => setProjectOptions([]));
|
||||
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
|
||||
}, [mode, seededProjectId, token]);
|
||||
}, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || mode !== "edit" || !workOrderId) {
|
||||
@@ -57,6 +82,8 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
setForm({
|
||||
itemId: workOrder.itemId,
|
||||
projectId: workOrder.projectId,
|
||||
salesOrderId: workOrder.salesOrderId,
|
||||
salesOrderLineId: workOrder.salesOrderLineId,
|
||||
status: workOrder.status,
|
||||
quantity: workOrder.quantity,
|
||||
warehouseId: workOrder.warehouseId,
|
||||
@@ -110,21 +137,24 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
navigate(mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{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>
|
||||
<p className="section-kicker">MANUFACTURING EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "NEW WORK ORDER" : "EDIT WORK ORDER"}</h3>
|
||||
</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
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel space-y-3">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
|
||||
@@ -168,7 +198,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
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">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
@@ -225,7 +255,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
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">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
@@ -265,9 +295,9 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span>
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<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>
|
||||
<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"}
|
||||
@@ -277,3 +307,4 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,13 +35,12 @@ export function WorkOrderListPage() {
|
||||
}, [query, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">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>
|
||||
<p className="section-kicker">MANUFACTURING</p>
|
||||
<h3 className="module-title">WORK ORDERS</h3>
|
||||
</div>
|
||||
{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">
|
||||
@@ -50,8 +49,8 @@ export function WorkOrderListPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px]">
|
||||
<section className="surface-panel">
|
||||
<div className="grid gap-2.5 xl:grid-cols-[minmax(0,1fr)_240px]">
|
||||
<label className="block">
|
||||
<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" />
|
||||
@@ -63,12 +62,12 @@ export function WorkOrderListPage() {
|
||||
</select>
|
||||
</label>
|
||||
</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>
|
||||
{workOrders.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
|
||||
<div className="rounded-[20px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[28px] border border-line/70 bg-surface/90 shadow-panel">
|
||||
<div className="overflow-hidden rounded-[20px] border border-line/70 bg-surface/90 shadow-panel">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/70">
|
||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
@@ -107,3 +106,4 @@ export function WorkOrderListPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export const workOrderStatusPalette: Record<WorkOrderStatus, string> = {
|
||||
export const emptyWorkOrderInput: WorkOrderInput = {
|
||||
itemId: "",
|
||||
projectId: null,
|
||||
salesOrderId: null,
|
||||
salesOrderLineId: null,
|
||||
status: "DRAFT",
|
||||
quantity: 1,
|
||||
warehouseId: "",
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { ProjectMilestoneStatus, WorkOrderSummaryDto } from "@mrp/shared";
|
||||
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
||||
import type { WorkOrderSummaryDto } from "@mrp/shared";
|
||||
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { projectMilestoneStatusPalette } from "./config";
|
||||
import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
|
||||
import { ProjectStatusBadge } from "./ProjectStatusBadge";
|
||||
|
||||
function formatCurrency(value: number | null) {
|
||||
return value === null ? "Not linked" : `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { projectId } = useParams();
|
||||
const [project, setProject] = useState<ProjectDetailDto | null>(null);
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
||||
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||
const [status, setStatus] = useState("Loading project...");
|
||||
const [updatingMilestoneId, setUpdatingMilestoneId] = useState<string | null>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
||||
|
||||
@@ -25,12 +33,17 @@ export function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
api.getProject(token, projectId)
|
||||
.then((nextProject) => {
|
||||
.then(async (nextProject) => {
|
||||
setProject(nextProject);
|
||||
setStatus("Project loaded.");
|
||||
return api.getWorkOrders(token, { projectId: nextProject.id });
|
||||
const [nextPlanning, nextWorkOrders] = await Promise.all([
|
||||
nextProject.salesOrderId ? api.getSalesOrderPlanning(token, nextProject.salesOrderId).catch(() => null) : Promise.resolve(null),
|
||||
api.getWorkOrders(token, { projectId: nextProject.id }),
|
||||
]);
|
||||
|
||||
setPlanning(nextPlanning);
|
||||
setWorkOrders(nextWorkOrders);
|
||||
})
|
||||
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load project.";
|
||||
setStatus(message);
|
||||
@@ -38,18 +51,116 @@ export function ProjectDetailPage() {
|
||||
}, [projectId, token]);
|
||||
|
||||
if (!project) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||
}
|
||||
|
||||
const sortedMilestones = [...project.milestones].sort((left, right) => {
|
||||
if (left.status === "COMPLETE" && right.status !== "COMPLETE") {
|
||||
return 1;
|
||||
}
|
||||
if (left.status !== "COMPLETE" && right.status === "COMPLETE") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (left.dueDate && right.dueDate) {
|
||||
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
||||
}
|
||||
if (left.dueDate) {
|
||||
return -1;
|
||||
}
|
||||
if (right.dueDate) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.sortOrder - right.sortOrder;
|
||||
});
|
||||
|
||||
const nextMilestone = sortedMilestones.find((milestone) => milestone.status !== "COMPLETE") ?? null;
|
||||
const activeWorkOrders = workOrders.filter(
|
||||
(workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD"
|
||||
);
|
||||
const nextWorkOrder = [...activeWorkOrders]
|
||||
.sort((left, right) => {
|
||||
if (left.dueDate && right.dueDate) {
|
||||
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
||||
}
|
||||
if (left.dueDate) {
|
||||
return -1;
|
||||
}
|
||||
if (right.dueDate) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.workOrderNumber.localeCompare(right.workOrderNumber);
|
||||
})[0] ?? null;
|
||||
const materialExceptionItems = planning
|
||||
? planning.items.filter((item) => item.uncoveredQuantity > 0 || item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0).slice(0, 5)
|
||||
: [];
|
||||
const topBuildRecommendation = planning?.items.find((item) => item.recommendedBuildQuantity > 0) ?? null;
|
||||
const topPurchaseRecommendation = planning?.items.find((item) => item.recommendedPurchaseQuantity > 0) ?? null;
|
||||
const completionPercent = project.rollups.milestoneCount > 0
|
||||
? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100)
|
||||
: 0;
|
||||
const readinessScore = project.cockpit.risk.readinessScore;
|
||||
const riskTone = project.cockpit.risk.riskLevel === "LOW"
|
||||
? "text-emerald-600 dark:text-emerald-300"
|
||||
: project.cockpit.risk.riskLevel === "MEDIUM"
|
||||
? "text-amber-600 dark:text-amber-300"
|
||||
: "text-rose-600 dark:text-rose-300";
|
||||
|
||||
async function updateMilestoneStatus(milestoneId: string, nextStatus: ProjectMilestoneStatus) {
|
||||
if (!token || !project) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingMilestoneId(milestoneId);
|
||||
setStatus("Updating milestone status...");
|
||||
try {
|
||||
const nextProject = await api.updateProjectMilestoneStatus(token, project.id, milestoneId, { status: nextStatus });
|
||||
setProject(nextProject);
|
||||
setStatus("Milestone status updated.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update milestone status.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setUpdatingMilestoneId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function milestoneQuickActions(currentStatus: ProjectMilestoneStatus) {
|
||||
if (currentStatus === "PLANNED") {
|
||||
return [
|
||||
{ status: "IN_PROGRESS" as const, label: "Start" },
|
||||
{ status: "BLOCKED" as const, label: "Block" },
|
||||
{ status: "COMPLETE" as const, label: "Complete" },
|
||||
];
|
||||
}
|
||||
if (currentStatus === "IN_PROGRESS") {
|
||||
return [
|
||||
{ status: "BLOCKED" as const, label: "Block" },
|
||||
{ status: "COMPLETE" as const, label: "Complete" },
|
||||
{ status: "PLANNED" as const, label: "Reset" },
|
||||
];
|
||||
}
|
||||
if (currentStatus === "BLOCKED") {
|
||||
return [
|
||||
{ status: "IN_PROGRESS" as const, label: "Resume" },
|
||||
{ status: "COMPLETE" as const, label: "Complete" },
|
||||
{ status: "PLANNED" as const, label: "Reset" },
|
||||
];
|
||||
}
|
||||
return [{ status: "IN_PROGRESS" as const, label: "Reopen" }];
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{project.projectNumber}</h3>
|
||||
<p className="section-kicker">PROJECT</p>
|
||||
<h3 className="module-title">{project.projectNumber}</h3>
|
||||
<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} />
|
||||
<ProjectPriorityBadge priority={project.priority} />
|
||||
</div>
|
||||
@@ -61,80 +172,219 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Customer</p><div className="mt-2 text-base font-bold text-text">{project.customerName}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Owner</p><div className="mt-2 text-base font-bold text-text">{project.ownerName || "Unassigned"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
||||
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Customer</p><div className="mt-1 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">Owner</p><div className="mt-1 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">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="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-1 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.openMilestoneCount} open</div></article>
|
||||
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div></article>
|
||||
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Linked Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.workOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.activeWorkOrderCount} active</div></article>
|
||||
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div></article>
|
||||
</section>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">PROJECT COCKPIT</p>
|
||||
</div>
|
||||
<div className="surface-panel-tight text-right">
|
||||
<div className="metric-kicker">Milestone Progress</div>
|
||||
<div className="mt-1 text-2xl font-bold text-text">{completionPercent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5 xl:grid-cols-4">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.commercial.activeDocumentTotal)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.commercial.activeDocumentNumber ? `${project.cockpit.commercial.activeDocumentNumber} - ${project.cockpit.commercial.activeDocumentStatus}` : "Link a quote or sales order"}</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Supply</p><div className="mt-2 text-base font-bold text-text">{planning ? planning.summary.uncoveredItemCount : project.cockpit.risk.shortageItemCount} shortage items</div><div className="mt-1 text-xs text-muted">{planning ? `Build ${planning.summary.totalBuildQuantity} - Buy ${planning.summary.totalPurchaseQuantity}` : `Uncovered qty ${project.cockpit.risk.totalUncoveredQuantity}`}</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Execution</p><div className="mt-2 text-base font-bold text-text">{project.rollups.activeWorkOrderCount} active work orders</div><div className="mt-1 text-xs text-muted">{nextWorkOrder ? `${nextWorkOrder.workOrderNumber} due ${nextWorkOrder.dueDate ? new Date(nextWorkOrder.dueDate).toLocaleDateString() : "unscheduled"}` : "No active work order due date"}</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Delivery</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.delivery.shipmentStatus ? project.cockpit.delivery.shipmentStatus.replaceAll("_", " ") : "Not linked"}</div><div className="mt-1 text-xs text-muted">{project.cockpit.delivery.shipmentNumber ? `${project.cockpit.delivery.shipmentNumber} - ${project.cockpit.delivery.packageCount} package(s)` : "Link a shipment to track delivery"}</div></article>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5 xl:grid-cols-3">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchasing Coverage</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.purchasing.totalReceivedQuantity}/{project.cockpit.purchasing.totalOrderedQuantity} received</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.linkedPurchaseOrderCount} linked PO(s) - {project.cockpit.purchasing.totalOutstandingQuantity} outstanding</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Readiness Score</p><div className={`mt-2 text-base font-bold ${riskTone}`}>{readinessScore}%</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.riskLevel} risk - {project.cockpit.risk.shortageItemCount} shortage item(s)</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Spend</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.purchasing.linkedLineValue.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.vendorCount} vendor(s) across {project.cockpit.purchasing.linkedLineCount} linked line(s)</div></article>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5 xl:grid-cols-4">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Booked Revenue</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.costs.bookedRevenue)}</div><div className="mt-1 text-xs text-muted">Quoted baseline {formatCurrency(project.cockpit.costs.quotedRevenue)}</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Commitment</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.linkedPurchaseCommitment.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Linked PO line value already committed</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Material Cost</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.plannedMaterialCost.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Issued so far ${project.cockpit.costs.issuedMaterialCost.toFixed(2)}</div></article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Load</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.costs.completedBuildQuantity}/{project.cockpit.costs.buildQuantity}</div><div className="mt-1 text-xs text-muted">{project.cockpit.costs.plannedOperationHours.toFixed(1)} planned operation hours</div></article>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Next Checkpoints</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 p-3"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone</div><div className="mt-2 font-semibold text-text">{nextMilestone ? nextMilestone.title : "All milestones complete"}</div><div className="mt-1 text-xs text-muted">{nextMilestone ? `${nextMilestone.status.replace("_", " ")} - ${nextMilestone.dueDate ? new Date(nextMilestone.dueDate).toLocaleDateString() : "No due date"}` : "No open milestone remains."}</div></div>
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 p-3"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Work Order</div><div className="mt-2 font-semibold text-text">{nextWorkOrder ? nextWorkOrder.workOrderNumber : "No active work orders"}</div><div className="mt-1 text-xs text-muted">{nextWorkOrder ? `${nextWorkOrder.itemSku} - ${nextWorkOrder.completedQuantity}/${nextWorkOrder.quantity} complete` : "Launch or link a work order to populate execution checkpoints."}</div></div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Watchlist</p>
|
||||
{materialExceptionItems.length === 0 ? <div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-surface/80 px-3 py-4 text-sm text-muted">No current build/buy exception items from linked sales-order planning.</div> : <div className="mt-4 space-y-3">{materialExceptionItems.map((item) => (<div key={item.itemId} className="rounded-[16px] border border-line/70 bg-surface/80 p-3"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{item.itemSku}</div><div className="mt-1 text-xs text-muted">{item.itemName}</div></div><div className="text-xs text-muted">Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}</div></div></div>))}</div>}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||
<article className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">ACTIONABLE COCKPIT</p>
|
||||
</div>
|
||||
<Link to="/planning/workbench" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Open workbench
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5 xl:grid-cols-2">
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Follow-Through</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{topBuildRecommendation ? topBuildRecommendation.itemSku : "No build recommendation"}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{topBuildRecommendation ? `Recommended build qty ${topBuildRecommendation.recommendedBuildQuantity}` : "Planning does not currently recommend a new build."}
|
||||
</div>
|
||||
{topBuildRecommendation && project.salesOrderId ? (
|
||||
<Link
|
||||
to={`/manufacturing/work-orders/new?projectId=${project.id}&itemId=${topBuildRecommendation.itemId}&salesOrderId=${project.salesOrderId}&quantity=${topBuildRecommendation.recommendedBuildQuantity}¬es=${encodeURIComponent(`Project cockpit launch from ${project.projectNumber}`)}`}
|
||||
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Launch work order
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Follow-Through</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{topPurchaseRecommendation ? topPurchaseRecommendation.itemSku : "No buy recommendation"}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{topPurchaseRecommendation ? `Recommended buy qty ${topPurchaseRecommendation.recommendedPurchaseQuantity}` : "Planning does not currently recommend a new purchase."}
|
||||
</div>
|
||||
{topPurchaseRecommendation && project.salesOrderId ? (
|
||||
<Link
|
||||
to={`/purchasing/orders/new?planningOrderId=${project.salesOrderId}&itemId=${topPurchaseRecommendation.itemId}`}
|
||||
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Launch purchase order
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
New project work order
|
||||
</Link>
|
||||
{project.salesOrderId ? (
|
||||
<Link to={`/sales/orders/${project.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Open sales order
|
||||
</Link>
|
||||
) : null}
|
||||
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Review purchasing
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
<article className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3"><div><p className="section-kicker">LINKED PURCHASING</p></div>{project.salesOrderId ? <Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</Link> : null}</div>
|
||||
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-4 space-y-2.5">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[16px] border border-line/70 bg-page/60 px-3 py-2.5 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
|
||||
</article>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">READINESS DRIVERS</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Risk posture</div><div className={`mt-2 text-lg font-bold ${riskTone}`}>{project.cockpit.risk.riskLevel}</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.outstandingPurchaseOrderCount} PO(s) still waiting on receipts.</div></div>
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Blocked milestones: <span className="font-semibold">{project.cockpit.risk.blockedMilestoneCount}</span></div>
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Overdue execution items: <span className="font-semibold">{project.cockpit.risk.overdueMilestoneCount + project.cockpit.risk.overdueWorkOrderCount}</span></div>
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Uncovered material quantity: <span className="font-semibold">{project.cockpit.risk.totalUncoveredQuantity}</span></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">VENDOR EXPOSURE</p>
|
||||
{project.cockpit.purchasing.vendors.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No supplier exposure exists until purchasing is linked.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (<div key={vendor.vendorId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{vendor.vendorName}</div><div className="mt-1 text-xs text-muted">{vendor.orderCount} linked order(s)</div></div><div className="text-right text-xs text-muted"><div>${vendor.linkedLineValue.toFixed(2)}</div><div>{vendor.outstandingQuantity} outstanding qty</div></div></div></div>))}</div>}
|
||||
</article>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">RECENT RECEIPTS</p>
|
||||
{project.cockpit.purchasing.recentReceipts.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No purchase receipts have been posted against linked project supply.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.recentReceipts.map((receipt) => (<div key={receipt.receiptId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{receipt.receiptNumber}</div><div className="mt-1 text-xs text-muted">{receipt.vendorName} - {receipt.purchaseOrderNumber}</div></div><div className="text-right text-xs text-muted"><div>{new Date(receipt.receivedAt).toLocaleDateString()}</div><div>{receipt.totalQuantity} units received</div></div></div></div>))}</div>}
|
||||
</article>
|
||||
</section>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p>
|
||||
<dl className="mt-5 grid gap-3">
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">CUSTOMER LINKAGE</p>
|
||||
<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">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>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Program Notes</p>
|
||||
<article className="surface-panel">
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Commercial + Delivery Links</p>
|
||||
<div className="mt-5 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">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>
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">COMMERCIAL + DELIVERY LINKS</p>
|
||||
<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">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>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Links</p>
|
||||
<p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
New work order
|
||||
</Link>
|
||||
) : null}
|
||||
<div><p className="section-kicker">MILESTONES</p></div>
|
||||
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
||||
</div>
|
||||
{workOrders.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are linked to this project yet.</div>
|
||||
{project.milestones.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-3 space-y-2">{project.milestones.map((milestone) => (<div key={milestone.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div>{canManage ? <div className="flex flex-wrap gap-2">{milestoneQuickActions(milestone.status).map((action) => (<button key={action.status} type="button" onClick={() => void updateMilestoneStatus(milestone.id, action.status)} disabled={updatingMilestoneId === milestone.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">{updatingMilestoneId === milestone.id ? "Saving..." : action.label}</button>))}</div> : null}</div></div>))}</div>}
|
||||
</section>
|
||||
{planning ? (
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">MATERIAL READINESS</p>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-4">
|
||||
<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="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="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="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 className="mt-3 space-y-2">
|
||||
{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 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>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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}
|
||||
</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-6 space-y-3">
|
||||
{workOrders.map((workOrder) => (
|
||||
<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{workOrder.workOrderNumber}</div>
|
||||
<div className="mt-1 text-xs text-muted">{workOrder.itemSku} · {workOrder.completedQuantity}/{workOrder.quantity} complete</div>
|
||||
<div 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 className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,23 @@ import type {
|
||||
ProjectCustomerOptionDto,
|
||||
ProjectDocumentOptionDto,
|
||||
ProjectInput,
|
||||
ProjectMilestoneInput,
|
||||
ProjectOwnerOptionDto,
|
||||
ProjectShipmentOptionDto,
|
||||
} from "@mrp/shared/dist/projects/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
|
||||
import { emptyProjectInput, projectMilestoneStatusOptions, projectPriorityOptions, projectStatusOptions } from "./config";
|
||||
|
||||
type ProjectPendingConfirmation =
|
||||
| { kind: "change-customer"; customerId: string; customerName: string }
|
||||
| { kind: "unlink-quote" }
|
||||
| { kind: "unlink-order" }
|
||||
| { kind: "unlink-shipment" };
|
||||
|
||||
export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const { token, user } = useAuth();
|
||||
@@ -34,6 +42,14 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
|
||||
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
|
||||
|
||||
function reindexMilestones(milestones: ProjectMilestoneInput[]) {
|
||||
return milestones.map((milestone, index) => ({
|
||||
...milestone,
|
||||
sortOrder: index * 10,
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -75,6 +91,14 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
ownerId: project.ownerId,
|
||||
dueDate: project.dueDate,
|
||||
notes: project.notes,
|
||||
milestones: project.milestones.map((milestone) => ({
|
||||
id: milestone.id,
|
||||
title: milestone.title,
|
||||
status: milestone.status,
|
||||
dueDate: milestone.dueDate,
|
||||
notes: milestone.notes,
|
||||
sortOrder: milestone.sortOrder,
|
||||
})),
|
||||
});
|
||||
setCustomerSearchTerm(project.customerName);
|
||||
setOwnerSearchTerm(project.ownerName ?? "");
|
||||
@@ -103,6 +127,43 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
}));
|
||||
}
|
||||
|
||||
function hasLinkedCommercialRecords() {
|
||||
return Boolean(form.salesQuoteId || form.salesOrderId || form.shipmentId);
|
||||
}
|
||||
|
||||
function applyCustomerSelection(customerId: string, customerName: string) {
|
||||
updateField("customerId", customerId);
|
||||
setCustomerSearchTerm(customerName);
|
||||
setCustomerPickerOpen(false);
|
||||
}
|
||||
|
||||
function requestCustomerSelection(customerId: string, customerName: string) {
|
||||
if (form.customerId && form.customerId !== customerId && hasLinkedCommercialRecords()) {
|
||||
setPendingConfirmation({ kind: "change-customer", customerId, customerName });
|
||||
return;
|
||||
}
|
||||
|
||||
applyCustomerSelection(customerId, customerName);
|
||||
}
|
||||
|
||||
function unlinkQuote() {
|
||||
updateField("salesQuoteId", null);
|
||||
setQuoteSearchTerm("");
|
||||
setQuotePickerOpen(false);
|
||||
}
|
||||
|
||||
function unlinkOrder() {
|
||||
updateField("salesOrderId", null);
|
||||
setOrderSearchTerm("");
|
||||
setOrderPickerOpen(false);
|
||||
}
|
||||
|
||||
function unlinkShipment() {
|
||||
updateField("shipmentId", null);
|
||||
setShipmentSearchTerm("");
|
||||
setShipmentPickerOpen(false);
|
||||
}
|
||||
|
||||
function restoreSearchTerms() {
|
||||
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
|
||||
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
|
||||
@@ -117,6 +178,44 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
|
||||
}
|
||||
|
||||
function addMilestone() {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: reindexMilestones([
|
||||
...current.milestones,
|
||||
{
|
||||
id: null,
|
||||
title: "",
|
||||
status: "PLANNED",
|
||||
dueDate: current.dueDate,
|
||||
notes: "",
|
||||
sortOrder: current.milestones.length * 10,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateMilestone<Key extends keyof ProjectMilestoneInput>(index: number, key: Key, value: ProjectMilestoneInput[Key]) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: current.milestones.map((milestone, milestoneIndex) =>
|
||||
milestoneIndex === index
|
||||
? {
|
||||
...milestone,
|
||||
[key]: value,
|
||||
}
|
||||
: milestone
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function removeMilestone(index: number) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: reindexMilestones(current.milestones.filter((_, milestoneIndex) => milestoneIndex !== index)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
@@ -136,20 +235,19 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{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>
|
||||
<p className="section-kicker">PROJECTS EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "New Project" : "Edit Project"}</h3>
|
||||
</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">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="space-y-3 surface-panel">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Project name</span>
|
||||
@@ -158,13 +256,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={customerSearchTerm}
|
||||
onChange={(event) => {
|
||||
setCustomerSearchTerm(event.target.value);
|
||||
updateField("customerId", "");
|
||||
setCustomerPickerOpen(true);
|
||||
}}
|
||||
<input
|
||||
value={customerSearchTerm}
|
||||
onChange={(event) => {
|
||||
setCustomerSearchTerm(event.target.value);
|
||||
setCustomerPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setCustomerPickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setCustomerPickerOpen(false);
|
||||
@@ -187,9 +284,7 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
.map((customer) => (
|
||||
<button key={customer.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("customerId", customer.id);
|
||||
setCustomerSearchTerm(customer.name);
|
||||
setCustomerPickerOpen(false);
|
||||
requestCustomerSelection(customer.id, customer.name);
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||
<div className="font-semibold text-text">{customer.name}</div>
|
||||
<div className="mt-1 text-xs text-muted">{customer.email}</div>
|
||||
@@ -274,13 +369,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Quote</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={quoteSearchTerm}
|
||||
onChange={(event) => {
|
||||
setQuoteSearchTerm(event.target.value);
|
||||
updateField("salesQuoteId", null);
|
||||
setQuotePickerOpen(true);
|
||||
}}
|
||||
<input
|
||||
value={quoteSearchTerm}
|
||||
onChange={(event) => {
|
||||
setQuoteSearchTerm(event.target.value);
|
||||
setQuotePickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setQuotePickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setQuotePickerOpen(false);
|
||||
@@ -293,9 +387,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("salesQuoteId", null);
|
||||
setQuoteSearchTerm("");
|
||||
setQuotePickerOpen(false);
|
||||
if (form.salesQuoteId) {
|
||||
setPendingConfirmation({ kind: "unlink-quote" });
|
||||
} else {
|
||||
unlinkQuote();
|
||||
}
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||
<div className="font-semibold text-text">No linked quote</div>
|
||||
</button>
|
||||
@@ -326,13 +422,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={orderSearchTerm}
|
||||
onChange={(event) => {
|
||||
setOrderSearchTerm(event.target.value);
|
||||
updateField("salesOrderId", null);
|
||||
setOrderPickerOpen(true);
|
||||
}}
|
||||
<input
|
||||
value={orderSearchTerm}
|
||||
onChange={(event) => {
|
||||
setOrderSearchTerm(event.target.value);
|
||||
setOrderPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setOrderPickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setOrderPickerOpen(false);
|
||||
@@ -345,9 +440,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("salesOrderId", null);
|
||||
setOrderSearchTerm("");
|
||||
setOrderPickerOpen(false);
|
||||
if (form.salesOrderId) {
|
||||
setPendingConfirmation({ kind: "unlink-order" });
|
||||
} else {
|
||||
unlinkOrder();
|
||||
}
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||
<div className="font-semibold text-text">No linked sales order</div>
|
||||
</button>
|
||||
@@ -378,13 +475,12 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={shipmentSearchTerm}
|
||||
onChange={(event) => {
|
||||
setShipmentSearchTerm(event.target.value);
|
||||
updateField("shipmentId", null);
|
||||
setShipmentPickerOpen(true);
|
||||
}}
|
||||
<input
|
||||
value={shipmentSearchTerm}
|
||||
onChange={(event) => {
|
||||
setShipmentSearchTerm(event.target.value);
|
||||
setShipmentPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setShipmentPickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setShipmentPickerOpen(false);
|
||||
@@ -397,9 +493,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("shipmentId", null);
|
||||
setShipmentSearchTerm("");
|
||||
setShipmentPickerOpen(false);
|
||||
if (form.shipmentId) {
|
||||
setPendingConfirmation({ kind: "unlink-shipment" });
|
||||
} else {
|
||||
unlinkShipment();
|
||||
}
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||
<div className="font-semibold text-text">No linked shipment</div>
|
||||
</button>
|
||||
@@ -430,8 +528,78 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text">Milestones</div>
|
||||
<div className="mt-1 text-xs text-muted">Track checkpoints, due dates, and blocked work inside the project record.</div>
|
||||
</div>
|
||||
<button type="button" onClick={addMilestone} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Add milestone
|
||||
</button>
|
||||
</div>
|
||||
{form.milestones.length === 0 ? (
|
||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-surface/80 px-3 py-4 text-sm text-muted">
|
||||
No milestones added yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{form.milestones.map((milestone, milestoneIndex) => (
|
||||
<div key={milestone.id ?? `new-milestone-${milestoneIndex}`} className="rounded-[18px] border border-line/70 bg-surface/80 p-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_180px_180px]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone</span>
|
||||
<input
|
||||
value={milestone.title}
|
||||
onChange={(event) => updateMilestone(milestoneIndex, "title", event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||
<select
|
||||
value={milestone.status}
|
||||
onChange={(event) => updateMilestone(milestoneIndex, "status", event.target.value as ProjectMilestoneInput["status"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{projectMilestoneStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Due date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={milestone.dueDate ? milestone.dueDate.slice(0, 10) : ""}
|
||||
onChange={(event) => updateMilestone(milestoneIndex, "dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_120px]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||
<textarea
|
||||
value={milestone.notes}
|
||||
onChange={(event) => updateMilestone(milestoneIndex, "notes", event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={() => removeMilestone(milestoneIndex)} className="w-full rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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">
|
||||
@@ -439,6 +607,61 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={
|
||||
pendingConfirmation?.kind === "change-customer"
|
||||
? "Change project customer"
|
||||
: pendingConfirmation?.kind === "unlink-quote"
|
||||
? "Remove linked quote"
|
||||
: pendingConfirmation?.kind === "unlink-order"
|
||||
? "Remove linked sales order"
|
||||
: "Remove linked shipment"
|
||||
}
|
||||
description={
|
||||
pendingConfirmation?.kind === "change-customer"
|
||||
? `Switch this project to ${pendingConfirmation.customerName}. Existing quote, sales order, and shipment links will be cleared.`
|
||||
: pendingConfirmation?.kind === "unlink-quote"
|
||||
? "Remove the currently linked quote from this project draft."
|
||||
: pendingConfirmation?.kind === "unlink-order"
|
||||
? "Remove the currently linked sales order from this project draft."
|
||||
: "Remove the currently linked shipment from this project draft."
|
||||
}
|
||||
impact={
|
||||
pendingConfirmation?.kind === "change-customer"
|
||||
? "Commercial and delivery linkage tied to the previous customer will be cleared immediately from the draft."
|
||||
: "The project will no longer point to that related record after you save this edit."
|
||||
}
|
||||
recovery={
|
||||
pendingConfirmation?.kind === "change-customer"
|
||||
? "Re-link the correct quote, order, and shipment before saving if the customer change was accidental."
|
||||
: "Pick the related record again before saving if this unlink was a mistake."
|
||||
}
|
||||
confirmLabel={
|
||||
pendingConfirmation?.kind === "change-customer"
|
||||
? "Change customer"
|
||||
: "Remove link"
|
||||
}
|
||||
onClose={() => setPendingConfirmation(null)}
|
||||
onConfirm={() => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "change-customer") {
|
||||
applyCustomerSelection(pendingConfirmation.customerId, pendingConfirmation.customerName);
|
||||
} else if (pendingConfirmation.kind === "unlink-quote") {
|
||||
unlinkQuote();
|
||||
} else if (pendingConfirmation.kind === "unlink-order") {
|
||||
unlinkOrder();
|
||||
} else if (pendingConfirmation.kind === "unlink-shipment") {
|
||||
unlinkShipment();
|
||||
}
|
||||
|
||||
setPendingConfirmation(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,13 +42,12 @@ export function ProjectListPage() {
|
||||
}, [priorityFilter, query, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">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>
|
||||
<p className="section-kicker">PROJECTS</p>
|
||||
<h3 className="module-title">PROGRAM RECORDS</h3>
|
||||
</div>
|
||||
{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">
|
||||
@@ -57,8 +56,8 @@ export function ProjectListPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
|
||||
<section className="surface-panel">
|
||||
<div className="grid gap-2.5 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
|
||||
<label className="block">
|
||||
<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" />
|
||||
@@ -76,16 +75,17 @@ export function ProjectListPage() {
|
||||
</select>
|
||||
</label>
|
||||
</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 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No projects are available for the current filters.</div>
|
||||
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No projects are available for the current filters.</div>
|
||||
) : (
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
|
||||
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-2">Project</th>
|
||||
<th className="px-2 py-2">Customer</th>
|
||||
<th className="px-2 py-2">Rollups</th>
|
||||
<th className="px-2 py-2">Owner</th>
|
||||
<th className="px-2 py-2">Status</th>
|
||||
<th className="px-2 py-2">Priority</th>
|
||||
@@ -100,6 +100,11 @@ export function ProjectListPage() {
|
||||
<div className="mt-1 text-xs text-muted">{project.name}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{project.customerName}</td>
|
||||
<td className="px-2 py-2 text-xs text-muted">
|
||||
<div>{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount} milestones</div>
|
||||
<div>{project.rollups.activeWorkOrderCount} active WO</div>
|
||||
<div>{project.rollups.overdueMilestoneCount + project.rollups.overdueWorkOrderCount} overdue items</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td>
|
||||
<td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td>
|
||||
<td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td>
|
||||
@@ -114,3 +119,4 @@ export function ProjectListPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProjectInput, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js";
|
||||
import type { ProjectInput, ProjectMilestoneStatus, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js";
|
||||
|
||||
export const projectStatusOptions: Array<{ value: ProjectStatus; label: string }> = [
|
||||
{ value: "PLANNED", label: "Planned" },
|
||||
@@ -40,6 +40,20 @@ export const projectPriorityPalette: Record<ProjectPriority, string> = {
|
||||
CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
|
||||
export const projectMilestoneStatusOptions: Array<{ value: ProjectMilestoneStatus; label: string }> = [
|
||||
{ value: "PLANNED", label: "Planned" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "BLOCKED", label: "Blocked" },
|
||||
{ value: "COMPLETE", label: "Complete" },
|
||||
];
|
||||
|
||||
export const projectMilestoneStatusPalette: Record<ProjectMilestoneStatus, string> = {
|
||||
PLANNED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
IN_PROGRESS: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
BLOCKED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
COMPLETE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
};
|
||||
|
||||
export const emptyProjectInput: ProjectInput = {
|
||||
name: "",
|
||||
status: "PLANNED",
|
||||
@@ -51,4 +65,5 @@ export const emptyProjectInput: ProjectInput = {
|
||||
ownerId: null,
|
||||
dueDate: null,
|
||||
notes: "",
|
||||
milestones: [],
|
||||
};
|
||||
|
||||
@@ -2,15 +2,73 @@ import { permissions } from "@mrp/shared";
|
||||
import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared";
|
||||
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
|
||||
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function mapPurchaseDocumentForComparison(
|
||||
document: Pick<
|
||||
PurchaseOrderDetailDto,
|
||||
| "documentNumber"
|
||||
| "vendorName"
|
||||
| "status"
|
||||
| "issueDate"
|
||||
| "taxPercent"
|
||||
| "taxAmount"
|
||||
| "freightAmount"
|
||||
| "subtotal"
|
||||
| "total"
|
||||
| "notes"
|
||||
| "paymentTerms"
|
||||
| "currencyCode"
|
||||
| "lines"
|
||||
| "receipts"
|
||||
>
|
||||
) {
|
||||
return {
|
||||
title: document.documentNumber,
|
||||
subtitle: document.vendorName,
|
||||
status: document.status,
|
||||
metaFields: [
|
||||
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
|
||||
{ label: "Payment Terms", value: document.paymentTerms || "N/A" },
|
||||
{ label: "Currency", value: document.currencyCode || "USD" },
|
||||
{ label: "Receipts", value: document.receipts.length.toString() },
|
||||
],
|
||||
totalFields: [
|
||||
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
|
||||
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
|
||||
{ label: "Freight", value: formatCurrency(document.freightAmount) },
|
||||
{ label: "Total", value: formatCurrency(document.total) },
|
||||
],
|
||||
notes: document.notes,
|
||||
lines: document.lines.map((line) => ({
|
||||
key: line.id || `${line.itemId}-${line.position}`,
|
||||
title: `${line.itemSku} | ${line.itemName}`,
|
||||
subtitle: line.description,
|
||||
quantity: `${line.quantity} ${line.unitOfMeasure}`,
|
||||
unitLabel: line.unitOfMeasure,
|
||||
amountLabel: formatCurrency(line.unitCost),
|
||||
totalLabel: formatCurrency(line.lineTotal),
|
||||
extraLabel:
|
||||
`${line.receivedQuantity} received | ${line.remainingQuantity} remaining` +
|
||||
(line.salesOrderNumber ? ` | Demand ${line.salesOrderNumber}` : ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function PurchaseDetailPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { orderId } = useParams();
|
||||
@@ -23,6 +81,21 @@ export function PurchaseDetailPage() {
|
||||
const [status, setStatus] = useState("Loading purchase order...");
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
|
||||
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "status" | "receipt";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
nextStatus?: PurchaseOrderStatus;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const canManage = user?.permissions.includes("purchasing.write") ?? false;
|
||||
const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false);
|
||||
@@ -41,6 +114,7 @@ export function PurchaseDetailPage() {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
|
||||
setStatus(message);
|
||||
});
|
||||
api.getDemandPlanningRollup(token).then(setPlanningRollup).catch(() => setPlanningRollup(null));
|
||||
|
||||
if (!canReceive) {
|
||||
return;
|
||||
@@ -85,11 +159,13 @@ export function PurchaseDetailPage() {
|
||||
}, [document]);
|
||||
|
||||
if (!document) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
const activeDocument = document;
|
||||
const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0);
|
||||
const demandContextItems =
|
||||
planningRollup?.items.filter((item) => activeDocument.lines.some((line) => line.itemId === item.itemId) && (item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)) ?? [];
|
||||
|
||||
function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) {
|
||||
setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value }));
|
||||
@@ -102,7 +178,7 @@ export function PurchaseDetailPage() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleStatusChange(nextStatus: PurchaseOrderStatus) {
|
||||
async function applyStatusChange(nextStatus: PurchaseOrderStatus) {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +189,7 @@ export function PurchaseDetailPage() {
|
||||
try {
|
||||
const nextDocument = await api.updatePurchaseOrderStatus(token, activeDocument.id, nextStatus);
|
||||
setDocument(nextDocument);
|
||||
setStatus("Purchase order status updated.");
|
||||
setStatus("Purchase order status updated. Confirm vendor communication and receiving expectations if this moved the order into a terminal state.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update purchase order status.";
|
||||
setStatus(message);
|
||||
@@ -122,8 +198,7 @@ export function PurchaseDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceiptSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function applyReceipt() {
|
||||
if (!token || !canReceive) {
|
||||
return;
|
||||
}
|
||||
@@ -150,7 +225,7 @@ export function PurchaseDetailPage() {
|
||||
receivedAt: new Date().toISOString(),
|
||||
notes: "",
|
||||
}));
|
||||
setReceiptStatus("Purchase receipt recorded.");
|
||||
setReceiptStatus("Purchase receipt recorded. Inventory has been increased; verify stock balances and post a correcting movement if quantities were overstated.");
|
||||
setStatus("Purchase order updated after receipt.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to record purchase receipt.";
|
||||
@@ -160,6 +235,39 @@ export function PurchaseDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: PurchaseOrderStatus) {
|
||||
const label = purchaseStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
|
||||
setPendingConfirmation({
|
||||
kind: "status",
|
||||
title: `Set purchase order to ${label}`,
|
||||
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
|
||||
impact:
|
||||
nextStatus === "CLOSED"
|
||||
? "This closes the order operationally and can change inbound supply expectations, shortage coverage, and vendor follow-up."
|
||||
: "This changes the purchasing state used by receiving, planning, and audit review.",
|
||||
recovery: "If the status is wrong, set the order back to the correct state and verify any downstream receiving or planning assumptions.",
|
||||
confirmLabel: `Set ${label}`,
|
||||
confirmationLabel: nextStatus === "CLOSED" ? "Type purchase order number to confirm:" : undefined,
|
||||
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
|
||||
nextStatus,
|
||||
});
|
||||
}
|
||||
|
||||
function handleReceiptSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const totalReceiptQuantity = openLines.reduce((sum, line) => sum + Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)), 0);
|
||||
setPendingConfirmation({
|
||||
kind: "receipt",
|
||||
title: "Post purchase receipt",
|
||||
description: `Receive ${totalReceiptQuantity} total units into ${receiptForm.warehouseId && receiptForm.locationId ? "the selected stock location" : "inventory"} for ${activeDocument.documentNumber}.`,
|
||||
impact: "This increases inventory immediately and becomes part of the PO receipt history.",
|
||||
recovery: "If quantities are wrong, post the correcting inventory movement and review the remaining quantities on the purchase order.",
|
||||
confirmLabel: "Post receipt",
|
||||
confirmationLabel: totalReceiptQuantity > 0 ? "Type purchase order number to confirm:" : undefined,
|
||||
confirmationValue: totalReceiptQuantity > 0 ? activeDocument.documentNumber : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOpenPdf() {
|
||||
if (!token) {
|
||||
return;
|
||||
@@ -184,14 +292,17 @@ export function PurchaseDetailPage() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Order</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
|
||||
<p className="section-kicker">PURCHASE ORDER</p>
|
||||
<h3 className="module-title">{activeDocument.documentNumber}</h3>
|
||||
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<PurchaseStatusBadge status={activeDocument.status} />
|
||||
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
Rev {activeDocument.revisions[0]?.revisionNumber ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -215,11 +326,10 @@ export function PurchaseDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||
<p className="mt-2 text-sm text-muted">Update purchase-order status without opening the full editor.</p>
|
||||
<p className="section-kicker">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{purchaseStatusOptions.map((option) => (
|
||||
@@ -231,48 +341,147 @@ export function PurchaseDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p><div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Receipts</p><div className="mt-2 text-base font-bold text-text">{activeDocument.receipts.length}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Qty Remaining</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}</div></article>
|
||||
<section className="grid gap-2 xl:grid-cols-4">
|
||||
<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="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="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="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 className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p><div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p><div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p><div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Payment Terms</p><div className="mt-2 text-base font-bold text-text">{activeDocument.paymentTerms || "N/A"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Currency</p><div className="mt-2 text-base font-bold text-text">{activeDocument.currencyCode || "USD"}</div></article>
|
||||
<section className="grid gap-2 xl:grid-cols-4">
|
||||
<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="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="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="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="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="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 className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">REVISION HISTORY</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeDocument.revisions.length === 0 ? (
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No revisions recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{activeDocument.revisions.map((revision) => (
|
||||
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
|
||||
<div className="mt-1 text-sm text-text">{revision.reason}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>{new Date(revision.createdAt).toLocaleString()}</div>
|
||||
<div className="mt-1">{revision.createdByName ?? "System"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{activeDocument.revisions.length > 0 ? (
|
||||
<DocumentRevisionComparison
|
||||
title="Revision Comparison"
|
||||
description="Compare earlier purchase-order revisions against the current document or another revision to review commercial, receiving, and line-level changes."
|
||||
currentLabel="Current document"
|
||||
currentDocument={mapPurchaseDocumentForComparison(activeDocument)}
|
||||
revisions={activeDocument.revisions.map((revision) => ({
|
||||
id: revision.id,
|
||||
label: `Rev ${revision.revisionNumber}`,
|
||||
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
|
||||
}))}
|
||||
getRevisionDocument={(revisionId) => {
|
||||
if (revisionId === "current") {
|
||||
return mapPurchaseDocumentForComparison(activeDocument);
|
||||
}
|
||||
|
||||
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
|
||||
if (!revision) {
|
||||
return mapPurchaseDocumentForComparison(activeDocument);
|
||||
}
|
||||
|
||||
return mapPurchaseDocumentForComparison({
|
||||
...revision.snapshot,
|
||||
lines: revision.snapshot.lines.map((line) => ({
|
||||
id: `${line.itemId}-${line.position}`,
|
||||
...line,
|
||||
})),
|
||||
receipts: revision.snapshot.receipts,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">VENDOR</p>
|
||||
<dl className="mt-5 grid gap-3">
|
||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><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>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||
<article className="surface-panel">
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||
{activeDocument.lines.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No line items have been added yet.</div>
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">DEMAND CONTEXT</p>
|
||||
{demandContextItems.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 active shortage or buy-signal context for these items.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||
<div className="mt-3 space-y-2">
|
||||
{demandContextItems.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">
|
||||
Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">LINE ITEMS</p>
|
||||
{activeDocument.lines.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 line items added yet.</div>
|
||||
) : (
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
|
||||
<tr key={line.id}>
|
||||
<td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td>
|
||||
<td className="px-2 py-2 text-muted">{line.description}</td>
|
||||
<td className="px-2 py-2 text-muted">
|
||||
{line.salesOrderId && line.salesOrderNumber ? <Link to={`/sales/orders/${line.salesOrderId}`} className="hover:text-brand">{line.salesOrderNumber}</Link> : "Unlinked"}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{line.quantity}</td>
|
||||
<td className="px-2 py-2 text-muted">{line.receivedQuantity}</td>
|
||||
<td className="px-2 py-2 text-muted">{line.remainingQuantity}</td>
|
||||
@@ -288,16 +497,14 @@ export function PurchaseDetailPage() {
|
||||
</section>
|
||||
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
|
||||
{canReceive ? (
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">PURCHASE RECEIVING</p>
|
||||
{openLines.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
All ordered quantities have been received for this purchase order.
|
||||
</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">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Receipt date</span>
|
||||
@@ -335,12 +542,12 @@ export function PurchaseDetailPage() {
|
||||
value={receiptForm.notes}
|
||||
onChange={(event) => updateReceiptField("notes", event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{openLines.map((line) => (
|
||||
<div key={line.id} className="grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[minmax(0,1.3fr)_0.6fr_0.7fr_0.7fr]">
|
||||
<div key={line.id} className="grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[minmax(0,1.3fr)_0.6fr_0.7fr_0.7fr]">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{line.itemSku}</div>
|
||||
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
|
||||
@@ -369,7 +576,7 @@ export function PurchaseDetailPage() {
|
||||
</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>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -383,17 +590,16 @@ export function PurchaseDetailPage() {
|
||||
)}
|
||||
</article>
|
||||
) : null}
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Receipt History</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Received material log</h4>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">RECEIPT HISTORY</p>
|
||||
{activeDocument.receipts.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No purchase receipts have been recorded for this order yet.
|
||||
<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 recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{activeDocument.receipts.map((receipt) => (
|
||||
<article key={receipt.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<article key={receipt.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text">{receipt.receiptNumber}</div>
|
||||
@@ -432,6 +638,42 @@ export function PurchaseDetailPage() {
|
||||
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet."
|
||||
/>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm purchasing action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={
|
||||
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||
(pendingConfirmation?.kind === "receipt" && isSavingReceipt)
|
||||
}
|
||||
onClose={() => {
|
||||
if (!isUpdatingStatus && !isSavingReceipt) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||
setPendingConfirmation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "receipt") {
|
||||
await applyReceipt();
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared";
|
||||
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { inventoryUnitOptions } from "../inventory/config";
|
||||
@@ -13,6 +14,11 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const { orderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
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 selectedPlanningItemId = searchParams.get("itemId");
|
||||
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
|
||||
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
|
||||
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
|
||||
@@ -22,6 +28,15 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
|
||||
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
|
||||
|
||||
function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] {
|
||||
const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : [];
|
||||
for (const child of node.children) {
|
||||
nodes.push(...collectRecommendedPurchaseNodes(child));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0);
|
||||
const taxAmount = subtotal * (form.taxPercent / 100);
|
||||
@@ -45,6 +60,82 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
|
||||
}, [mode, seededVendorId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "create" && seededProjectId) {
|
||||
setForm((current) => ({ ...current, projectId: current.projectId || seededProjectId }));
|
||||
}
|
||||
}, [mode, seededProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getSalesOrderPlanning(token, planningOrderId)
|
||||
.then((planning: SalesOrderPlanningDto) => {
|
||||
const recommendedNodes = planning.lines.flatMap((line) =>
|
||||
collectRecommendedPurchaseNodes(line.rootNode).map((node) => ({
|
||||
salesOrderLineId: node.itemId === line.itemId ? line.lineId : null,
|
||||
...node,
|
||||
}))
|
||||
);
|
||||
const filteredNodes = selectedPlanningItemId
|
||||
? recommendedNodes.filter((node) => node.itemId === selectedPlanningItemId)
|
||||
: recommendedNodes;
|
||||
const recommendedLines = filteredNodes.map((node, index) => {
|
||||
const inventoryItem = itemOptions.find((option) => option.id === node.itemId);
|
||||
return {
|
||||
itemId: node.itemId,
|
||||
description: node.itemName,
|
||||
quantity: node.recommendedPurchaseQuantity,
|
||||
unitOfMeasure: node.unitOfMeasure,
|
||||
unitCost: inventoryItem?.defaultCost ?? 0,
|
||||
salesOrderId: planning.orderId,
|
||||
salesOrderLineId: node.salesOrderLineId,
|
||||
position: (index + 1) * 10,
|
||||
} satisfies PurchaseLineInput;
|
||||
});
|
||||
|
||||
if (recommendedLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredVendorIds = [
|
||||
...new Set(
|
||||
recommendedLines
|
||||
.map((line) => itemOptions.find((option) => option.id === line.itemId)?.preferredVendorId)
|
||||
.filter((vendorId): vendorId is string => Boolean(vendorId))
|
||||
),
|
||||
];
|
||||
const autoVendorId = seededVendorId || (preferredVendorIds.length === 1 ? preferredVendorIds[0] : null);
|
||||
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
vendorId: current.vendorId || autoVendorId || "",
|
||||
projectId: current.projectId || seededProjectId || null,
|
||||
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
|
||||
lines: current.lines.length > 0 ? current.lines : recommendedLines,
|
||||
}));
|
||||
if (autoVendorId) {
|
||||
const autoVendor = vendors.find((vendor) => vendor.id === autoVendorId);
|
||||
if (autoVendor) {
|
||||
setVendorSearchTerm(autoVendor.name);
|
||||
}
|
||||
}
|
||||
setLineSearchTerms((current) =>
|
||||
current.length > 0 ? current : recommendedLines.map((line) => itemOptions.find((option) => option.id === line.itemId)?.sku ?? "")
|
||||
);
|
||||
setStatus(
|
||||
preferredVendorIds.length > 1 && !seededVendorId
|
||||
? `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}. Multiple preferred vendors exist, so confirm the vendor before saving.`
|
||||
: `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}.`
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus("Unable to load demand-planning recommendations.");
|
||||
});
|
||||
}, [itemOptions, mode, planningOrderId, seededProjectId, seededVendorId, selectedPlanningItemId, token, vendors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || mode !== "edit" || !orderId) {
|
||||
return;
|
||||
@@ -54,17 +145,21 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
.then((document) => {
|
||||
setForm({
|
||||
vendorId: document.vendorId,
|
||||
projectId: document.projectId,
|
||||
status: document.status,
|
||||
issueDate: document.issueDate,
|
||||
taxPercent: document.taxPercent,
|
||||
freightAmount: document.freightAmount,
|
||||
notes: document.notes,
|
||||
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number }) => ({
|
||||
revisionReason: "",
|
||||
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
|
||||
itemId: line.itemId,
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
unitOfMeasure: line.unitOfMeasure,
|
||||
unitCost: line.unitCost,
|
||||
salesOrderId: line.salesOrderId,
|
||||
salesOrderLineId: line.salesOrderLineId,
|
||||
position: line.position,
|
||||
})),
|
||||
});
|
||||
@@ -131,6 +226,15 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index));
|
||||
}
|
||||
|
||||
const pendingLineRemoval =
|
||||
pendingLineRemovalIndex != null
|
||||
? {
|
||||
index: pendingLineRemovalIndex,
|
||||
line: form.lines[pendingLineRemovalIndex],
|
||||
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
|
||||
}
|
||||
: null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
@@ -159,20 +263,24 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
||||
}).length;
|
||||
|
||||
function closeEditor() {
|
||||
navigate(mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
|
||||
<p className="section-kicker">PURCHASING EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
|
||||
</div>
|
||||
<Link to={mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
<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
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="space-y-3 surface-panel">
|
||||
<div className="grid gap-3 xl:grid-cols-4">
|
||||
<label className="block xl:col-span-2">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Vendor</span>
|
||||
@@ -248,10 +356,29 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</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">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
{mode === "edit" ? (
|
||||
<label className="block xl:max-w-xl">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Revision Reason</span>
|
||||
<input
|
||||
value={form.revisionReason ?? ""}
|
||||
onChange={(event) => updateField("revisionReason", event.target.value)}
|
||||
placeholder="What changed in this revision?"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
|
||||
@@ -263,20 +390,20 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Procurement lines</h4>
|
||||
<p className="section-kicker">LINE ITEMS</p>
|
||||
<h4 className="text-lg font-bold text-text">PROCUREMENT LINES</h4>
|
||||
</div>
|
||||
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add line</button>
|
||||
</div>
|
||||
{form.lines.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No line items added yet.</div>
|
||||
<div className="mt-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) => (
|
||||
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
|
||||
@@ -308,6 +435,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
...line,
|
||||
itemId: option.id,
|
||||
description: line.description || option.name,
|
||||
salesOrderId: line.salesOrderId ?? null,
|
||||
salesOrderLineId: line.salesOrderLineId ?? null,
|
||||
});
|
||||
updateLineSearchTerm(index, option.sku);
|
||||
setActiveLinePicker(null);
|
||||
@@ -342,25 +471,45 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
<input type="number" min={0} step={0.01} value={line.unitCost} onChange={(event) => updateLine(index, { ...line, unitCost: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex items-end"><div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">${(line.quantity * line.unitCost).toFixed(2)}</div></div>
|
||||
<div className="flex items-end"><button type="button" onClick={() => removeLine(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">Remove</button></div>
|
||||
<div className="flex items-end"><button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">Remove</button></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div 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">Tax</div><div className="mt-1 font-semibold text-text">${taxAmount.toFixed(2)}</div></div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Freight</div><div className="mt-1 font-semibold text-text">${form.freightAmount.toFixed(2)}</div></div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div><div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div></div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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>
|
||||
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{isSaving ? "Saving..." : mode === "create" ? "Create purchase order" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={pendingLineRemoval != null}
|
||||
title="Remove purchase line"
|
||||
description={
|
||||
pendingLineRemoval
|
||||
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the purchase order draft.`
|
||||
: "Remove this purchase line."
|
||||
}
|
||||
impact="The line will be removed from the draft immediately and purchasing totals will recalculate."
|
||||
recovery="Re-add the line before saving if the removal was accidental."
|
||||
confirmLabel="Remove line"
|
||||
onClose={() => setPendingLineRemovalIndex(null)}
|
||||
onConfirm={() => {
|
||||
if (pendingLineRemoval) {
|
||||
removeLine(pendingLineRemoval.index);
|
||||
}
|
||||
setPendingLineRemovalIndex(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,12 +34,11 @@ export function PurchaseListPage() {
|
||||
}, [searchTerm, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Purchase Orders</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted">Vendor-facing procurement documents for material replenishment and bought-in components.</p>
|
||||
<p className="section-kicker">PURCHASING</p>
|
||||
<h3 className="module-title">PURCHASE ORDERS</h3>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Link to="/purchasing/orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
@@ -47,7 +46,7 @@ export function PurchaseListPage() {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
||||
<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">
|
||||
<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" />
|
||||
@@ -63,11 +62,11 @@ export function PurchaseListPage() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<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 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase orders have been added yet.</div>
|
||||
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No purchase orders have been added yet.</div>
|
||||
) : (
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
@@ -97,3 +96,4 @@ export function PurchaseListPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
|
||||
|
||||
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
||||
vendorId: "",
|
||||
projectId: null,
|
||||
status: "DRAFT",
|
||||
issueDate: new Date().toISOString(),
|
||||
taxPercent: 0,
|
||||
freightAmount: 0,
|
||||
notes: "",
|
||||
revisionReason: "",
|
||||
lines: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,107 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { SalesDocumentDetailDto, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
|
||||
import type { SalesDocumentDetailDto, SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js";
|
||||
import type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||
import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
|
||||
import { SalesStatusBadge } from "./SalesStatusBadge";
|
||||
import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge";
|
||||
|
||||
function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
|
||||
return (
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3" style={{ marginLeft: node.level * 12 }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">
|
||||
{node.itemSku} <span className="text-muted">{node.itemName}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
Demand {node.grossDemand} {node.unitOfMeasure} · Type {node.itemType}
|
||||
{node.bomQuantityPerParent !== null ? ` · Qty/parent ${node.bomQuantityPerParent}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>Linked WO {node.linkedWorkOrderSupply}</div>
|
||||
<div>Linked PO {node.linkedPurchaseSupply}</div>
|
||||
<div>Stock {node.supplyFromStock}</div>
|
||||
<div>Open WO {node.supplyFromOpenWorkOrders}</div>
|
||||
<div>Open PO {node.supplyFromOpenPurchaseOrders}</div>
|
||||
<div>Build {node.recommendedBuildQuantity}</div>
|
||||
<div>Buy {node.recommendedPurchaseQuantity}</div>
|
||||
{node.uncoveredQuantity > 0 ? <div>Uncovered {node.uncoveredQuantity}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{node.children.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{node.children.map((child) => (
|
||||
<PlanningNodeCard key={`${child.itemId}-${child.level}-${child.itemSku}-${child.grossDemand}`} node={child} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function mapSalesDocumentForComparison(
|
||||
document: Pick<
|
||||
SalesDocumentDetailDto,
|
||||
| "documentNumber"
|
||||
| "customerName"
|
||||
| "status"
|
||||
| "issueDate"
|
||||
| "expiresAt"
|
||||
| "approvedAt"
|
||||
| "approvedByName"
|
||||
| "discountAmount"
|
||||
| "discountPercent"
|
||||
| "taxAmount"
|
||||
| "taxPercent"
|
||||
| "freightAmount"
|
||||
| "subtotal"
|
||||
| "total"
|
||||
| "notes"
|
||||
| "lines"
|
||||
>
|
||||
) {
|
||||
return {
|
||||
title: document.documentNumber,
|
||||
subtitle: document.customerName,
|
||||
status: document.status,
|
||||
metaFields: [
|
||||
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
|
||||
{ label: "Expires", value: document.expiresAt ? new Date(document.expiresAt).toLocaleDateString() : "N/A" },
|
||||
{ label: "Approval", value: document.approvedAt ? new Date(document.approvedAt).toLocaleDateString() : "Pending" },
|
||||
{ label: "Approver", value: document.approvedByName ?? "No approver recorded" },
|
||||
],
|
||||
totalFields: [
|
||||
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
|
||||
{ label: "Discount", value: `${formatCurrency(document.discountAmount)} (${document.discountPercent.toFixed(2)}%)` },
|
||||
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
|
||||
{ label: "Freight", value: formatCurrency(document.freightAmount) },
|
||||
{ label: "Total", value: formatCurrency(document.total) },
|
||||
],
|
||||
notes: document.notes,
|
||||
lines: document.lines.map((line) => ({
|
||||
key: line.id || `${line.itemId}-${line.position}`,
|
||||
title: `${line.itemSku} | ${line.itemName}`,
|
||||
subtitle: line.description,
|
||||
quantity: `${line.quantity} ${line.unitOfMeasure}`,
|
||||
unitLabel: line.unitOfMeasure,
|
||||
amountLabel: formatCurrency(line.unitPrice),
|
||||
totalLabel: formatCurrency(line.lineTotal),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
const { token, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -23,10 +115,27 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
|
||||
const [isApproving, setIsApproving] = useState(false);
|
||||
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "status" | "approve" | "convert";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
nextStatus?: SalesDocumentStatus;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
|
||||
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false;
|
||||
const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false;
|
||||
const canManageManufacturing = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||
const canManagePurchasing = user?.permissions.includes(permissions.purchasingWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !documentId) {
|
||||
@@ -34,9 +143,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
}
|
||||
|
||||
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
|
||||
loader
|
||||
.then((nextDocument) => {
|
||||
const planningLoader = entity === "order" ? api.getSalesOrderPlanning(token, documentId) : Promise.resolve(null);
|
||||
Promise.all([loader, planningLoader])
|
||||
.then(([nextDocument, nextPlanning]) => {
|
||||
setDocument(nextDocument);
|
||||
setPlanning(nextPlanning);
|
||||
setStatus(`${config.singularLabel} loaded.`);
|
||||
if (entity === "order" && canReadShipping) {
|
||||
api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([]));
|
||||
@@ -49,12 +160,49 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
}, [canReadShipping, config.singularLabel, documentId, entity, token]);
|
||||
|
||||
if (!document) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
const activeDocument = document;
|
||||
|
||||
async function handleStatusChange(nextStatus: SalesDocumentStatus) {
|
||||
function buildWorkOrderRecommendationLink(itemId: string, quantity: number) {
|
||||
const params = new URLSearchParams({
|
||||
itemId,
|
||||
salesOrderId: activeDocument.id,
|
||||
quantity: quantity.toString(),
|
||||
status: "DRAFT",
|
||||
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
|
||||
});
|
||||
if (activeDocument.linkedProjectId) {
|
||||
params.set("projectId", activeDocument.linkedProjectId);
|
||||
}
|
||||
|
||||
return `/manufacturing/work-orders/new?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildPurchaseRecommendationLink(itemId?: string, vendorId?: string | null) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("planningOrderId", activeDocument.id);
|
||||
if (itemId) {
|
||||
params.set("itemId", itemId);
|
||||
}
|
||||
if (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()}`;
|
||||
}
|
||||
|
||||
async function applyStatusChange(nextStatus: SalesDocumentStatus) {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +216,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
? await api.updateQuoteStatus(token, activeDocument.id, nextStatus)
|
||||
: await api.updateSalesOrderStatus(token, activeDocument.id, nextStatus);
|
||||
setDocument(nextDocument);
|
||||
setStatus(`${config.singularLabel} status updated.`);
|
||||
setStatus(`${config.singularLabel} status updated. Review revisions and downstream workflows if the document moved into a terminal or customer-visible state.`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to update ${config.singularLabel.toLowerCase()} status.`;
|
||||
setStatus(message);
|
||||
@@ -77,7 +225,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
async function applyConvert() {
|
||||
if (!token || entity !== "quote") {
|
||||
return;
|
||||
}
|
||||
@@ -120,7 +268,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
async function applyApprove() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
@@ -132,7 +280,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
const nextDocument =
|
||||
entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id);
|
||||
setDocument(nextDocument);
|
||||
setStatus(`${config.singularLabel} approved.`);
|
||||
setStatus(`${config.singularLabel} approved. The approval stamp is now part of the document history and downstream teams can act on it immediately.`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to approve ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
@@ -141,13 +289,57 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: SalesDocumentStatus) {
|
||||
const label = salesStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
|
||||
setPendingConfirmation({
|
||||
kind: "status",
|
||||
title: `Set ${config.singularLabel.toLowerCase()} to ${label}`,
|
||||
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
|
||||
impact:
|
||||
nextStatus === "CLOSED"
|
||||
? "This closes the document operationally and can change customer-facing execution assumptions and downstream follow-up expectations."
|
||||
: nextStatus === "APPROVED"
|
||||
? "This marks the document ready for downstream action and becomes part of the approval history."
|
||||
: "This changes the operational state used by downstream workflows and audit/revision history.",
|
||||
recovery: "If this status is set in error, return the document to the correct state and verify the latest revision history.",
|
||||
confirmLabel: `Set ${label}`,
|
||||
confirmationLabel: nextStatus === "CLOSED" ? "Type document number to confirm:" : undefined,
|
||||
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
|
||||
nextStatus,
|
||||
});
|
||||
}
|
||||
|
||||
function handleApprove() {
|
||||
setPendingConfirmation({
|
||||
kind: "approve",
|
||||
title: `Approve ${config.singularLabel.toLowerCase()}`,
|
||||
description: `Approve ${activeDocument.documentNumber} for ${activeDocument.customerName}.`,
|
||||
impact: "Approval records the approver and timestamp and signals that downstream execution can proceed.",
|
||||
recovery: "If approval was granted by mistake, change the document status and review the revision trail for follow-up.",
|
||||
confirmLabel: "Approve document",
|
||||
});
|
||||
}
|
||||
|
||||
function handleConvert() {
|
||||
setPendingConfirmation({
|
||||
kind: "convert",
|
||||
title: "Convert quote to sales order",
|
||||
description: `Create a sales order from quote ${activeDocument.documentNumber}.`,
|
||||
impact: "This creates a new sales order record and can trigger planning, purchasing, manufacturing, and shipping follow-up work.",
|
||||
recovery: "Review the new order immediately after creation. If conversion was premature, move the resulting order to the correct status and coordinate with downstream teams.",
|
||||
confirmLabel: "Convert quote",
|
||||
confirmationLabel: "Type quote number to confirm:",
|
||||
confirmationValue: activeDocument.documentNumber,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<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="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow}</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
|
||||
<p className="section-kicker">{config.detailEyebrow.toUpperCase()}</p>
|
||||
<h3 className="module-title">{activeDocument.documentNumber}</h3>
|
||||
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<SalesStatusBadge status={activeDocument.status} />
|
||||
@@ -204,11 +396,10 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||
<p className="mt-2 text-sm text-muted">Update document status without opening the full editor.</p>
|
||||
<p className="section-kicker">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{salesStatusOptions.map((option) => (
|
||||
@@ -226,60 +417,59 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<section className="grid gap-2 xl:grid-cols-4">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<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>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<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-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<section className="grid gap-2 xl:grid-cols-4">
|
||||
<article className="surface-panel-tight">
|
||||
<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-1 text-xs text-muted">{activeDocument.discountPercent.toFixed(2)}%</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p>
|
||||
<div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div>
|
||||
<div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<article className="surface-panel-tight">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p>
|
||||
<div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div>
|
||||
</article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<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>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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>
|
||||
<p className="section-kicker">REVISION HISTORY</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeDocument.revisions.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No revisions have been recorded yet.
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No revisions recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{activeDocument.revisions.map((revision) => (
|
||||
<article key={revision.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
|
||||
@@ -295,9 +485,40 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{activeDocument.revisions.length > 0 ? (
|
||||
<DocumentRevisionComparison
|
||||
title="Revision Comparison"
|
||||
description="Compare a prior revision against the current document or another revision to see commercial and line-level changes."
|
||||
currentLabel="Current document"
|
||||
currentDocument={mapSalesDocumentForComparison(activeDocument)}
|
||||
revisions={activeDocument.revisions.map((revision) => ({
|
||||
id: revision.id,
|
||||
label: `Rev ${revision.revisionNumber}`,
|
||||
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
|
||||
}))}
|
||||
getRevisionDocument={(revisionId) => {
|
||||
if (revisionId === "current") {
|
||||
return mapSalesDocumentForComparison(activeDocument);
|
||||
}
|
||||
|
||||
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
|
||||
if (!revision) {
|
||||
return mapSalesDocumentForComparison(activeDocument);
|
||||
}
|
||||
|
||||
return mapSalesDocumentForComparison({
|
||||
...revision.snapshot,
|
||||
lines: revision.snapshot.lines.map((line) => ({
|
||||
id: `${line.itemId}-${line.position}`,
|
||||
...line,
|
||||
})),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer</p>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">CUSTOMER</p>
|
||||
<dl className="mt-5 grid gap-3">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt>
|
||||
@@ -309,19 +530,30 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||
<article className="surface-panel">
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">LINE ITEMS</p>
|
||||
{activeDocument.lines.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No line items have been added yet.
|
||||
<div 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">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
@@ -352,12 +584,126 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{entity === "order" && planning ? (
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">DEMAND PLANNING</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
|
||||
<div>Status {planning.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 xl:grid-cols-4">
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
<article className="surface-panel-tight bg-page/70 shadow-none">
|
||||
<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-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
|
||||
</article>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-2">Item</th>
|
||||
<th className="px-2 py-2">Gross</th>
|
||||
<th className="px-2 py-2">Linked WO</th>
|
||||
<th className="px-2 py-2">Linked PO</th>
|
||||
<th className="px-2 py-2">Available</th>
|
||||
<th className="px-2 py-2">Open WO</th>
|
||||
<th className="px-2 py-2">Open PO</th>
|
||||
<th className="px-2 py-2">Build</th>
|
||||
<th className="px-2 py-2">Buy</th>
|
||||
<th className="px-2 py-2">Uncovered</th>
|
||||
<th className="px-2 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{planning.items.map((item) => (
|
||||
<tr key={item.itemId}>
|
||||
<td className="px-2 py-2">
|
||||
<div className="font-semibold text-text">{item.itemSku}</div>
|
||||
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{item.grossDemand}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.linkedWorkOrderSupply}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.linkedPurchaseSupply}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.availableQuantity}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.openWorkOrderSupply}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.openPurchaseSupply}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.recommendedBuildQuantity}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.recommendedPurchaseQuantity}</td>
|
||||
<td className="px-2 py-2 text-muted">{item.uncoveredQuantity}</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManageManufacturing && item.recommendedBuildQuantity > 0 ? (
|
||||
<Link
|
||||
to={buildWorkOrderRecommendationLink(item.itemId, item.recommendedBuildQuantity)}
|
||||
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
|
||||
>
|
||||
Draft WO
|
||||
</Link>
|
||||
) : null}
|
||||
{canManagePurchasing && item.recommendedPurchaseQuantity > 0 ? (
|
||||
<Link
|
||||
to={buildPurchaseRecommendationLink(item.itemId)}
|
||||
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
|
||||
>
|
||||
Draft PO
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{canManagePurchasing && planning.summary.purchaseRecommendationCount > 0 ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Link to={buildPurchaseRecommendationLink()} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Draft purchase order from recommendations
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 space-y-2">
|
||||
{planning.lines.map((line) => (
|
||||
<div key={line.lineId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="mb-3">
|
||||
<div className="font-semibold text-text">
|
||||
{line.itemSku} <span className="text-muted">{line.itemName}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
Sales-order line demand: {line.quantity} {line.unitOfMeasure}
|
||||
</div>
|
||||
</div>
|
||||
<PlanningNodeCard node={line.rootNode} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{entity === "order" && canReadShipping ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
|
||||
<p className="mt-2 text-sm text-muted">Shipment records currently tied to this sales order.</p>
|
||||
<p className="section-kicker">SHIPPING</p>
|
||||
</div>
|
||||
{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">
|
||||
@@ -366,13 +712,13 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
) : null}
|
||||
</div>
|
||||
{shipments.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
No shipments have been created for this sales order yet.
|
||||
<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 created yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{shipments.map((shipment) => (
|
||||
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
|
||||
@@ -386,6 +732,49 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm sales action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={
|
||||
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||
(pendingConfirmation?.kind === "approve" && isApproving) ||
|
||||
(pendingConfirmation?.kind === "convert" && isConverting)
|
||||
}
|
||||
onClose={() => {
|
||||
if (!isUpdatingStatus && !isApproving && !isConverting) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||
setPendingConfirmation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "approve") {
|
||||
await applyApprove();
|
||||
setPendingConfirmation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "convert") {
|
||||
await applyConvert();
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { inventoryUnitOptions } from "../inventory/config";
|
||||
@@ -23,6 +24,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
|
||||
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
|
||||
|
||||
const subtotal = form.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
|
||||
const discountAmount = subtotal * (form.discountPercent / 100);
|
||||
@@ -129,6 +131,15 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index));
|
||||
}
|
||||
|
||||
const pendingLineRemoval =
|
||||
pendingLineRemovalIndex != null
|
||||
? {
|
||||
index: pendingLineRemovalIndex,
|
||||
line: form.lines[pendingLineRemovalIndex],
|
||||
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
|
||||
}
|
||||
: null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
@@ -156,20 +167,24 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
navigate(mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow} Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
|
||||
<p className="section-kicker">{`${config.detailEyebrow} EDITOR`.toUpperCase()}</p>
|
||||
<h3 className="module-title">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
|
||||
</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
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="space-y-3 surface-panel">
|
||||
<div className="grid gap-3 xl:grid-cols-4">
|
||||
<label className="block xl:col-span-2">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
|
||||
@@ -288,7 +303,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
value={form.notes}
|
||||
onChange={(event) => updateField("notes", event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
{mode === "edit" ? (
|
||||
@@ -340,24 +355,24 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Commercial lines</h4>
|
||||
<p className="section-kicker">LINE ITEMS</p>
|
||||
<h4 className="text-lg font-bold text-text">COMMERCIAL LINES</h4>
|
||||
</div>
|
||||
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Add line
|
||||
</button>
|
||||
</div>
|
||||
{form.lines.length === 0 ? (
|
||||
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<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: SalesLineInput, index: number) => (
|
||||
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
|
||||
@@ -431,7 +446,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={() => removeLine(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
<button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
@@ -440,7 +455,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
))}
|
||||
</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="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div>
|
||||
<div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div>
|
||||
@@ -458,13 +473,34 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
<div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</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>
|
||||
<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"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={pendingLineRemoval != null}
|
||||
title={`Remove ${config.singularLabel.toLowerCase()} line`}
|
||||
description={
|
||||
pendingLineRemoval
|
||||
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the ${config.singularLabel.toLowerCase()}.`
|
||||
: "Remove this line."
|
||||
}
|
||||
impact="The line will be dropped from the document draft immediately and totals will recalculate."
|
||||
recovery="Add the line back manually before saving if this removal was a mistake."
|
||||
confirmLabel="Remove line"
|
||||
isConfirming={false}
|
||||
onClose={() => setPendingLineRemovalIndex(null)}
|
||||
onConfirm={() => {
|
||||
if (pendingLineRemoval) {
|
||||
removeLine(pendingLineRemoval.index);
|
||||
}
|
||||
setPendingLineRemovalIndex(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,14 +40,11 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.listEyebrow}</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">{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>
|
||||
<p className="section-kicker">{config.listEyebrow.toUpperCase()}</p>
|
||||
<h3 className="module-title">{config.collectionLabel}</h3>
|
||||
</div>
|
||||
{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">
|
||||
@@ -55,7 +52,7 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
||||
<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">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||
<input
|
||||
@@ -80,13 +77,13 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<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 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
|
||||
No {config.collectionLabel.toLowerCase()} have been added yet.
|
||||
</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">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
@@ -127,3 +124,4 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
447
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
447
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto } from "@mrp/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function parseMetadata(metadataJson: string) {
|
||||
try {
|
||||
return JSON.parse(metadataJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminDiagnosticsPage() {
|
||||
const { token } = useAuth();
|
||||
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
|
||||
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
|
||||
const [supportLogData, setSupportLogData] = useState<SupportLogListDto | null>(null);
|
||||
const [status, setStatus] = useState("Loading diagnostics...");
|
||||
const [supportLogLevel, setSupportLogLevel] = useState<"ALL" | SupportLogEntryDto["level"]>("ALL");
|
||||
const [supportLogSource, setSupportLogSource] = useState("ALL");
|
||||
const [supportLogQuery, setSupportLogQuery] = useState("");
|
||||
const [supportLogWindowDays, setSupportLogWindowDays] = useState<"ALL" | "1" | "7" | "14">("ALL");
|
||||
|
||||
function buildSupportLogFilters(): SupportLogFiltersDto {
|
||||
const now = new Date();
|
||||
const start =
|
||||
supportLogWindowDays === "ALL"
|
||||
? undefined
|
||||
: new Date(now.getTime() - Number.parseInt(supportLogWindowDays, 10) * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
return {
|
||||
level: supportLogLevel === "ALL" ? undefined : supportLogLevel,
|
||||
source: supportLogSource === "ALL" ? undefined : supportLogSource,
|
||||
query: supportLogQuery.trim() || undefined,
|
||||
start,
|
||||
limit: 100,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token, buildSupportLogFilters())])
|
||||
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setDiagnostics(nextDiagnostics);
|
||||
setBackupGuidance(nextBackupGuidance);
|
||||
setSupportLogData(nextSupportLogs);
|
||||
setStatus("Diagnostics loaded.");
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setStatus(error.message || "Unable to load diagnostics.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
||||
|
||||
if (!diagnostics || !backupGuidance) {
|
||||
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||
}
|
||||
|
||||
async function handleExportSupportSnapshot() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await api.getSupportSnapshotWithFilters(token, buildSupportLogFilters());
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = `mrp-codex-support-snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||
link.click();
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||
setStatus("Support snapshot exported.");
|
||||
}
|
||||
|
||||
async function handleExportSupportLogs() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = await api.getSupportLogs(token, buildSupportLogFilters());
|
||||
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" });
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||
link.click();
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||
setSupportLogData(logs);
|
||||
setStatus("Support logs exported.");
|
||||
}
|
||||
|
||||
const supportLogs = supportLogData?.entries ?? [];
|
||||
const supportLogSummary = supportLogData?.summary;
|
||||
const supportLogSources = supportLogData?.availableSources ?? [];
|
||||
|
||||
const summaryCards = [
|
||||
["Server time", formatDateTime(diagnostics.serverTime)],
|
||||
["Node runtime", diagnostics.nodeVersion],
|
||||
["Audit events", diagnostics.auditEventCount.toString()],
|
||||
["Support logs", diagnostics.supportLogCount.toString()],
|
||||
["Retention", `${supportLogSummary?.retentionDays ?? 0} days`],
|
||||
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
|
||||
["Sessions to review", diagnostics.reviewSessionCount.toString()],
|
||||
["Sales docs", diagnostics.salesDocumentCount.toString()],
|
||||
["Work orders", diagnostics.workOrderCount.toString()],
|
||||
["Projects", diagnostics.projectCount.toString()],
|
||||
["Attachments", diagnostics.attachmentCount.toString()],
|
||||
];
|
||||
|
||||
const footprintCards = [
|
||||
["Database URL", diagnostics.databaseUrl],
|
||||
["Data directory", diagnostics.dataDir],
|
||||
["Uploads directory", diagnostics.uploadsDir],
|
||||
["Client origin", diagnostics.clientOrigin],
|
||||
["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"],
|
||||
["Active sessions", diagnostics.activeSessionCount.toString()],
|
||||
["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`],
|
||||
["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`],
|
||||
["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`],
|
||||
["Purchase orders", diagnostics.purchaseOrderCount.toString()],
|
||||
["Shipments", diagnostics.shipmentCount.toString()],
|
||||
];
|
||||
|
||||
const startupStatusTone =
|
||||
diagnostics.startup.status === "PASS"
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200"
|
||||
: diagnostics.startup.status === "WARN"
|
||||
? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200"
|
||||
: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200";
|
||||
|
||||
const startupSummaryCards = [
|
||||
["Generated", formatDateTime(diagnostics.startup.generatedAt)],
|
||||
["Duration", `${diagnostics.startup.durationMs} ms`],
|
||||
["Pass / Warn / Fail", `${diagnostics.startup.passCount} / ${diagnostics.startup.warnCount} / ${diagnostics.startup.failCount}`],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">ADMIN DIAGNOSTICS</p>
|
||||
<h3 className="module-title">RUNTIME AUDIT SUPPORT</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportSupportSnapshot}
|
||||
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
|
||||
>
|
||||
Export support bundle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportSupportLogs}
|
||||
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
|
||||
>
|
||||
Export support logs
|
||||
</button>
|
||||
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
User management
|
||||
</Link>
|
||||
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Company settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map(([label, value]) => (
|
||||
<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="mt-2 text-lg font-bold text-text">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">BACKUP AND RESTORE</p>
|
||||
</div>
|
||||
<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>DB: {backupGuidance.databasePath}</div>
|
||||
<div>Uploads: {backupGuidance.uploadsPath}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
<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>
|
||||
<div className="mt-3 space-y-2">
|
||||
{backupGuidance.backupSteps.map((step) => (
|
||||
<div key={step.id}>
|
||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||
<p className="mt-1 text-sm text-muted">{step.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="mt-3 space-y-2">
|
||||
{backupGuidance.restoreSteps.map((step) => (
|
||||
<div key={step.id}>
|
||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||
<p className="mt-1 text-sm text-muted">{step.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
<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>
|
||||
<div className="mt-3 space-y-2">
|
||||
{backupGuidance.verificationChecklist.map((item) => (
|
||||
<div key={item.id}>
|
||||
<p className="text-sm font-semibold text-text">{item.label}</p>
|
||||
<p className="mt-1 text-sm text-muted">{item.detail}</p>
|
||||
<p className="mt-1 text-xs text-muted">Evidence: {item.evidence}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="mt-3 space-y-2">
|
||||
{backupGuidance.restoreDrillSteps.map((step) => (
|
||||
<div key={step.id}>
|
||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||
<p className="mt-1 text-sm text-muted">{step.detail}</p>
|
||||
<p className="mt-1 text-xs text-muted">Expected outcome: {step.expectedOutcome}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">STARTUP VALIDATION</p>
|
||||
</div>
|
||||
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
|
||||
{diagnostics.startup.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
{diagnostics.startup.checks.map((check) => (
|
||||
<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">
|
||||
<p className="text-sm font-semibold text-text">{check.label}</p>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{check.status}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">{check.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-3">
|
||||
{startupSummaryCards.map(([label, value]) => (
|
||||
<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="mt-2 text-sm text-text">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<p className="section-kicker">SYSTEM FOOTPRINT</p>
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||
{footprintCards.map(([label, value]) => (
|
||||
<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="mt-2 break-all text-sm text-text">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">SUPPORT LOGS</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||
<input
|
||||
value={supportLogQuery}
|
||||
onChange={(event) => setSupportLogQuery(event.target.value)}
|
||||
placeholder="Message, source, context"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level</span>
|
||||
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
<option value="ALL">All levels</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARN">Warn</option>
|
||||
<option value="INFO">Info</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Source</span>
|
||||
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
<option value="ALL">All sources</option>
|
||||
{supportLogSources.map((source) => (
|
||||
<option key={source} value={source}>{source}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Window</span>
|
||||
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
<option value="ALL">All retained</option>
|
||||
<option value="1">Last 24 hours</option>
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="14">Last 14 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
|
||||
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
|
||||
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
<th className="px-3 py-3">When</th>
|
||||
<th className="px-3 py-3">Level</th>
|
||||
<th className="px-3 py-3">Source</th>
|
||||
<th className="px-3 py-3">Message</th>
|
||||
<th className="px-3 py-3">Context</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
{supportLogs.map((entry) => {
|
||||
const context = parseMetadata(entry.contextJson);
|
||||
return (
|
||||
<tr key={entry.id} className="align-top">
|
||||
<td className="px-3 py-3 text-muted">{formatDateTime(entry.createdAt)}</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
|
||||
{entry.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-text">{entry.source}</td>
|
||||
<td className="px-3 py-3 text-text">{entry.message}</td>
|
||||
<td className="px-3 py-3 text-xs text-muted">
|
||||
{Object.keys(context).length > 0 ? JSON.stringify(context) : "No context"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{supportLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-muted">
|
||||
No support logs matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">RECENT AUDIT TRAIL</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{status}</p>
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
<th className="px-3 py-3">When</th>
|
||||
<th className="px-3 py-3">Actor</th>
|
||||
<th className="px-3 py-3">Entity</th>
|
||||
<th className="px-3 py-3">Action</th>
|
||||
<th className="px-3 py-3">Summary</th>
|
||||
<th className="px-3 py-3">Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
{diagnostics.recentAuditEvents.map((event) => {
|
||||
const metadata = parseMetadata(event.metadataJson);
|
||||
return (
|
||||
<tr key={event.id} className="align-top">
|
||||
<td className="px-3 py-3 text-muted">{formatDateTime(event.createdAt)}</td>
|
||||
<td className="px-3 py-3 text-text">{event.actorName ?? "System"}</td>
|
||||
<td className="px-3 py-3 text-text">
|
||||
<div>{event.entityType}</div>
|
||||
{event.entityId ? <div className="text-xs text-muted">{event.entityId}</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
|
||||
{event.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-text">{event.summary}</td>
|
||||
<td className="px-3 py-3 text-xs text-muted">
|
||||
{Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "No metadata"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CompanyProfileInput } from "@mrp/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
@@ -6,7 +7,7 @@ import { api } from "../../lib/api";
|
||||
import { useTheme } from "../../theme/ThemeProvider";
|
||||
|
||||
export function CompanySettingsPage() {
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const { applyBrandProfile } = useTheme();
|
||||
const [form, setForm] = useState<CompanyProfileInput | null>(null);
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
@@ -92,7 +93,7 @@ export function CompanySettingsPage() {
|
||||
}, [logoUrl]);
|
||||
|
||||
if (!form || !token) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||
}
|
||||
|
||||
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -144,15 +145,32 @@ export function CompanySettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSave}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">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>
|
||||
<form className="page-stack" onSubmit={handleSave}>
|
||||
{user?.permissions.includes("admin.manage") ? (
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">ADMIN</p>
|
||||
<h3 className="module-title">ADMIN SURFACES</h3>
|
||||
</div>
|
||||
<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">
|
||||
User management
|
||||
</Link>
|
||||
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Open diagnostics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-dashed border-line/70 bg-page/80 p-4">
|
||||
</section>
|
||||
) : null}
|
||||
<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">COMPANY PROFILE</p>
|
||||
<h3 className="module-title">BRANDING AND LEGAL IDENTITY</h3>
|
||||
</div>
|
||||
<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>}
|
||||
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
|
||||
Upload logo
|
||||
@@ -160,7 +178,7 @@ export function CompanySettingsPage() {
|
||||
</label>
|
||||
</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"],
|
||||
["legalName", "Legal name"],
|
||||
@@ -176,37 +194,37 @@ export function CompanySettingsPage() {
|
||||
["country", "Country"],
|
||||
].map(([key, label]) => (
|
||||
<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
|
||||
value={String(form[key as keyof CompanyProfileInput])}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-4">
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">THEME</p>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 2xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">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" />
|
||||
<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" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">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" />
|
||||
<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" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">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" />
|
||||
<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" />
|
||||
</label>
|
||||
<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" />
|
||||
</label>
|
||||
</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>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
@@ -225,3 +243,4 @@ export function CompanySettingsPage() {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
668
client/src/modules/settings/UserManagementPage.tsx
Normal file
668
client/src/modules/settings/UserManagementPage.tsx
Normal file
@@ -0,0 +1,668 @@
|
||||
import type {
|
||||
AdminAuthSessionDto,
|
||||
AdminPermissionOptionDto,
|
||||
AdminRoleDto,
|
||||
AdminRoleInput,
|
||||
AdminUserDto,
|
||||
AdminUserInput,
|
||||
} from "@mrp/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
|
||||
const emptyUserForm: AdminUserInput = {
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
isActive: true,
|
||||
roleIds: [],
|
||||
password: "",
|
||||
};
|
||||
|
||||
const emptyRoleForm: AdminRoleInput = {
|
||||
name: "",
|
||||
description: "",
|
||||
permissionKeys: [],
|
||||
};
|
||||
|
||||
export function UserManagementPage() {
|
||||
const { token, user: authUser, logout } = useAuth();
|
||||
const [users, setUsers] = useState<AdminUserDto[]>([]);
|
||||
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
|
||||
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminAuthSessionDto[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>("new");
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
|
||||
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
|
||||
const [sessionStatusFilter, setSessionStatusFilter] = useState<"ALL" | AdminAuthSessionDto["status"]>("ALL");
|
||||
const [sessionReviewFilter, setSessionReviewFilter] = useState<"ALL" | AdminAuthSessionDto["reviewState"]>("ALL");
|
||||
const [sessionQuery, setSessionQuery] = useState("");
|
||||
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
|
||||
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
|
||||
const [status, setStatus] = useState("Loading admin access controls...");
|
||||
const [isConfirmingAction, setIsConfirmingAction] = useState(false);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "deactivate-user" | "revoke-session";
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token), api.getAdminSessions(token)])
|
||||
.then(([nextUsers, nextRoles, nextPermissions, nextSessions]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions);
|
||||
setSessions(nextSessions);
|
||||
setStatus("User management loaded.");
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setStatus(error.message || "Unable to load admin access controls.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUserId === "new") {
|
||||
setUserForm(emptyUserForm);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedUser = users.find((user) => user.id === selectedUserId);
|
||||
if (!selectedUser) {
|
||||
setUserForm(emptyUserForm);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserForm({
|
||||
email: selectedUser.email,
|
||||
firstName: selectedUser.firstName,
|
||||
lastName: selectedUser.lastName,
|
||||
isActive: selectedUser.isActive,
|
||||
roleIds: selectedUser.roleIds,
|
||||
password: "",
|
||||
});
|
||||
}, [selectedUserId, users]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRoleId === "new") {
|
||||
setRoleForm(emptyRoleForm);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRole = roles.find((role) => role.id === selectedRoleId);
|
||||
if (!selectedRole) {
|
||||
setRoleForm(emptyRoleForm);
|
||||
return;
|
||||
}
|
||||
|
||||
setRoleForm({
|
||||
name: selectedRole.name,
|
||||
description: selectedRole.description,
|
||||
permissionKeys: selectedRole.permissionKeys,
|
||||
});
|
||||
}, [roles, selectedRoleId]);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authToken = token;
|
||||
|
||||
async function refreshData(nextStatus: string) {
|
||||
const [nextUsers, nextRoles, nextPermissions, nextSessions] = await Promise.all([
|
||||
api.getAdminUsers(authToken),
|
||||
api.getAdminRoles(authToken),
|
||||
api.getAdminPermissions(authToken),
|
||||
api.getAdminSessions(authToken),
|
||||
]);
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions);
|
||||
setSessions(nextSessions);
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
|
||||
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const selectedUser = users.find((entry) => entry.id === selectedUserId);
|
||||
if (selectedUser && selectedUser.isActive && !userForm.isActive) {
|
||||
setPendingConfirmation({
|
||||
kind: "deactivate-user",
|
||||
title: `Deactivate ${selectedUser.firstName} ${selectedUser.lastName}`,
|
||||
description: `Disable sign-in for ${selectedUser.email}. Existing active sessions will remain revoked only if you separately revoke them below.`,
|
||||
impact: "The user will be blocked from new sign-ins as soon as this save completes.",
|
||||
recovery: "Re-enable the account later if the change was made in error, and revoke live sessions separately if immediate cut-off is required.",
|
||||
confirmLabel: "Deactivate user",
|
||||
confirmationLabel: "Type user email to confirm:",
|
||||
confirmationValue: selectedUser.email,
|
||||
userId: selectedUser.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveUser();
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof Error ? error.message : "Unable to save user.");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const normalizedUserForm: AdminUserInput = {
|
||||
...userForm,
|
||||
password: userForm.password && userForm.password.trim().length > 0 ? userForm.password : null,
|
||||
};
|
||||
|
||||
if (selectedUserId === "new") {
|
||||
const createdUser = await api.createAdminUser(authToken, normalizedUserForm);
|
||||
await refreshData(`Created user ${createdUser.email}.`);
|
||||
setSelectedUserId(createdUser.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUser = await api.updateAdminUser(authToken, selectedUserId, normalizedUserForm);
|
||||
await refreshData(`Updated user ${updatedUser.email}.`);
|
||||
setSelectedUserId(updatedUser.id);
|
||||
}
|
||||
|
||||
async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (selectedRoleId === "new") {
|
||||
const createdRole = await api.createAdminRole(authToken, roleForm);
|
||||
await refreshData(`Created role ${createdRole.name}.`);
|
||||
setSelectedRoleId(createdRole.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm);
|
||||
await refreshData(`Updated role ${updatedRole.name}.`);
|
||||
setSelectedRoleId(updatedRole.id);
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof Error ? error.message : "Unable to save role.");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUserRole(roleId: string) {
|
||||
setUserForm((current) => ({
|
||||
...current,
|
||||
roleIds: current.roleIds.includes(roleId)
|
||||
? current.roleIds.filter((currentRoleId) => currentRoleId !== roleId)
|
||||
: [...current.roleIds, roleId],
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleRolePermission(permissionKey: string) {
|
||||
setRoleForm((current) => ({
|
||||
...current,
|
||||
permissionKeys: current.permissionKeys.includes(permissionKey)
|
||||
? current.permissionKeys.filter((currentPermissionKey) => currentPermissionKey !== permissionKey)
|
||||
: [...current.permissionKeys, permissionKey],
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSessionRevoke(sessionId: string, isCurrentSession: boolean) {
|
||||
await api.revokeAdminSession(authToken, sessionId);
|
||||
if (isCurrentSession) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshData("Revoked session. The user must sign in again to restore access unless their account is inactive.");
|
||||
}
|
||||
|
||||
const normalizedSessionQuery = sessionQuery.trim().toLowerCase();
|
||||
const filteredSessions = sessions.filter((session) => {
|
||||
if (sessionUserFilter !== "all" && session.userId !== sessionUserFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sessionStatusFilter !== "ALL" && session.status !== sessionStatusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sessionReviewFilter !== "ALL" && session.reviewState !== sessionReviewFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedSessionQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
session.userName.toLowerCase().includes(normalizedSessionQuery) ||
|
||||
session.userEmail.toLowerCase().includes(normalizedSessionQuery) ||
|
||||
(session.ipAddress ?? "").toLowerCase().includes(normalizedSessionQuery) ||
|
||||
(session.userAgent ?? "").toLowerCase().includes(normalizedSessionQuery) ||
|
||||
session.reviewReasons.some((reason) => reason.toLowerCase().includes(normalizedSessionQuery))
|
||||
);
|
||||
});
|
||||
const activeSessionCount = sessions.filter((session) => session.status === "ACTIVE").length;
|
||||
const revokedSessionCount = sessions.filter((session) => session.status === "REVOKED").length;
|
||||
const expiredSessionCount = sessions.filter((session) => session.status === "EXPIRED").length;
|
||||
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">ADMIN</p>
|
||||
<h3 className="module-title">USERS ROLES SESSIONS</h3>
|
||||
</div>
|
||||
<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">
|
||||
Company settings
|
||||
</Link>
|
||||
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Diagnostics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<form className="surface-panel backdrop-blur" onSubmit={handleUserSave}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">USERS</p>
|
||||
</div>
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(event) => setSelectedUserId(event.target.value)}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
<option value="new">New user</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.firstName} {user.lastName} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Email</span>
|
||||
<input
|
||||
value={userForm.email}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={userForm.password ?? ""}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
|
||||
placeholder={selectedUserId === "new" ? "Required for new user" : "Leave blank to keep current password"}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">First name</span>
|
||||
<input
|
||||
value={userForm.firstName}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Last name</span>
|
||||
<input
|
||||
value={userForm.lastName}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={userForm.isActive}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, isActive: event.target.checked }))}
|
||||
/>
|
||||
User can sign in
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<p className="section-kicker">ASSIGNED ROLES</p>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{roles.map((role) => (
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={userForm.roleIds.includes(role.id)}
|
||||
onChange={() => toggleUserRole(role.id)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-semibold">{role.name}</span>
|
||||
<span className="block text-xs text-muted">{role.description || "No description"}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||
{selectedUserId === "new" ? "Create user" : "Save user"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form className="surface-panel backdrop-blur" onSubmit={handleRoleSave}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">ROLES</p>
|
||||
</div>
|
||||
<select
|
||||
value={selectedRoleId}
|
||||
onChange={(event) => setSelectedRoleId(event.target.value)}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
<option value="new">New role</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Role name</span>
|
||||
<input
|
||||
value={roleForm.name}
|
||||
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||
<textarea
|
||||
value={roleForm.description}
|
||||
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<p className="section-kicker">ROLE PERMISSIONS</p>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{permissions.map((permission) => (
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={roleForm.permissionKeys.includes(permission.key)}
|
||||
onChange={() => toggleRolePermission(permission.key)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-semibold">{permission.key}</span>
|
||||
<span className="block text-xs text-muted">{permission.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-3">
|
||||
{roles.map((role) => (
|
||||
<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="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
|
||||
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||
{selectedRoleId === "new" ? "Create role" : "Save role"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="surface-panel backdrop-blur">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">SESSIONS</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||
<input
|
||||
value={sessionQuery}
|
||||
onChange={(event) => setSessionQuery(event.target.value)}
|
||||
placeholder="User, email, IP, agent, review reason"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">User</span>
|
||||
<select
|
||||
value={sessionUserFilter}
|
||||
onChange={(event) => setSessionUserFilter(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
<option value="all">All users</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.firstName} {user.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||
<select
|
||||
value={sessionStatusFilter}
|
||||
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
<option value="ALL">All statuses</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
<option value="REVOKED">Revoked</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Review</span>
|
||||
<select
|
||||
value={sessionReviewFilter}
|
||||
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
<option value="ALL">All sessions</option>
|
||||
<option value="REVIEW">Needs review</option>
|
||||
<option value="NORMAL">Normal</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Active</p>
|
||||
<p className="mt-2 text-2xl font-bold text-text">{activeSessionCount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Revoked</p>
|
||||
<p className="mt-2 text-2xl font-bold text-text">{revokedSessionCount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Expired</p>
|
||||
<p className="mt-2 text-2xl font-bold text-text">{expiredSessionCount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-amber-300/60 bg-amber-50 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Needs Review</p>
|
||||
<p className="mt-2 text-2xl font-bold text-amber-900">{reviewSessionCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
{filteredSessions.map((session) => (
|
||||
<div key={session.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-text">{session.userName}</p>
|
||||
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
{session.status}
|
||||
</span>
|
||||
{session.reviewState === "REVIEW" ? (
|
||||
<span className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">
|
||||
Review
|
||||
</span>
|
||||
) : null}
|
||||
{session.isCurrent ? (
|
||||
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
|
||||
Current
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted">{session.userEmail}</p>
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted md:grid-cols-2 xl:grid-cols-4">
|
||||
<p>Started: {new Date(session.createdAt).toLocaleString()}</p>
|
||||
<p>Last seen: {new Date(session.lastSeenAt).toLocaleString()}</p>
|
||||
<p>Expires: {new Date(session.expiresAt).toLocaleString()}</p>
|
||||
<p>IP: {session.ipAddress || "Unknown"}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
|
||||
{session.reviewReasons.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{session.reviewReasons.map((reason) => (
|
||||
<span key={reason} className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-800">
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{session.revokedAt ? (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Revoked {new Date(session.revokedAt).toLocaleString()}
|
||||
{session.revokedByName ? ` by ${session.revokedByName}` : ""}.
|
||||
{session.revokedReason ? ` ${session.revokedReason}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{session.status === "ACTIVE" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPendingConfirmation({
|
||||
kind: "revoke-session",
|
||||
title: session.isCurrent ? "Revoke current session" : `Revoke session for ${session.userName}`,
|
||||
description: session.isCurrent
|
||||
? "Revoke the session you are using right now. Your current browser session will lose access immediately."
|
||||
: `Revoke the selected active session for ${session.userEmail}.`,
|
||||
impact: "The selected token becomes unusable immediately.",
|
||||
recovery: "The user can sign in again unless the account itself is inactive. Review the remaining session list after revocation.",
|
||||
confirmLabel: "Revoke session",
|
||||
confirmationLabel: session.isCurrent ? "Type REVOKE to confirm:" : undefined,
|
||||
confirmationValue: session.isCurrent ? "REVOKE" : undefined,
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
className="rounded-2xl border border-red-300 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
|
||||
>
|
||||
Revoke session
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-line/70 bg-page/40 px-3 py-6 text-sm text-muted">
|
||||
No sessions match the current filter.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm admin action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={isConfirmingAction}
|
||||
onClose={() => {
|
||||
if (!isConfirmingAction) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmingAction(true);
|
||||
|
||||
try {
|
||||
if (pendingConfirmation.kind === "deactivate-user" && pendingConfirmation.userId) {
|
||||
await saveUser();
|
||||
}
|
||||
|
||||
if (pendingConfirmation.kind === "revoke-session" && pendingConfirmation.sessionId) {
|
||||
const isCurrentSession = sessions.find((session) => session.id === pendingConfirmation.sessionId)?.isCurrent ?? false;
|
||||
await handleSessionRevoke(pendingConfirmation.sessionId, isCurrentSession);
|
||||
}
|
||||
|
||||
if (
|
||||
pendingConfirmation.kind === "deactivate-user" &&
|
||||
pendingConfirmation.userId &&
|
||||
pendingConfirmation.userId === authUser?.id
|
||||
) {
|
||||
setStatus("Your own account was deactivated. Sign-in will fail after this session ends unless another admin re-enables the account.");
|
||||
}
|
||||
|
||||
setPendingConfirmation(null);
|
||||
} finally {
|
||||
setIsConfirmingAction(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,107 @@
|
||||
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 { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { shipmentStatusOptions } from "./config";
|
||||
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() {
|
||||
const { token, user } = useAuth();
|
||||
const { shipmentId } = useParams();
|
||||
const [shipment, setShipment] = useState<ShipmentDetailDto | null>(null);
|
||||
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 [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isPostingPick, setIsPostingPick] = useState(false);
|
||||
const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
confirmLabel: string;
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
nextStatus: ShipmentStatus;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
|
||||
|
||||
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(() => {
|
||||
if (!token || !shipmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getShipment(token, shipmentId)
|
||||
.then((nextShipment) => {
|
||||
setShipment(nextShipment);
|
||||
setStatus("Shipment loaded.");
|
||||
return api.getShipments(token, { salesOrderId: nextShipment.salesOrderId });
|
||||
})
|
||||
.then((shipments) => setRelatedShipments(shipments.filter((candidate) => candidate.id !== shipmentId)))
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [shipmentId, token]);
|
||||
loadShipmentDetail(token, shipmentId).catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [shipmentId, token, canManage]);
|
||||
|
||||
async function handleStatusChange(nextStatus: ShipmentStatus) {
|
||||
const selectedLine = shipment?.lines.find((line) => line.salesOrderLineId === pickForm.salesOrderLineId) ?? null;
|
||||
const availableLocations = locationOptions.filter((location) => !pickForm.warehouseId || location.warehouseId === pickForm.warehouseId);
|
||||
const warehouseOptions = Array.from(
|
||||
new Map(locationOptions.map((location) => [location.warehouseId, { id: location.warehouseId, label: `${location.warehouseCode} · ${location.warehouseName}` }])).values()
|
||||
);
|
||||
const totalOrderedQuantity = shipment?.lines.reduce((sum, line) => sum + line.orderedQuantity, 0) ?? 0;
|
||||
const totalPickedQuantity = shipment?.lines.reduce((sum, line) => sum + line.pickedQuantity, 0) ?? 0;
|
||||
|
||||
async function applyStatusChange(nextStatus: ShipmentStatus) {
|
||||
if (!token || !shipment) {
|
||||
return;
|
||||
}
|
||||
@@ -48,7 +111,8 @@ export function ShipmentDetailPage() {
|
||||
try {
|
||||
const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus);
|
||||
setShipment(nextShipment);
|
||||
setStatus("Shipment status updated.");
|
||||
setPickForm((current) => buildInitialPickForm(nextShipment, locationOptions, current));
|
||||
setStatus("Shipment status updated. Verify carrier paperwork, inventory issue progress, and sales-order expectations.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to update shipment status.";
|
||||
setStatus(message);
|
||||
@@ -57,6 +121,29 @@ export function ShipmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: ShipmentStatus) {
|
||||
if (!shipment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = shipmentStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
|
||||
setPendingConfirmation({
|
||||
title: `Set shipment to ${label}`,
|
||||
description: `Update shipment ${shipment.shipmentNumber} from ${shipment.status} to ${nextStatus}.`,
|
||||
impact:
|
||||
nextStatus === "DELIVERED"
|
||||
? "This marks delivery complete and can affect customer communication, project delivery status, and shipment closeout review."
|
||||
: nextStatus === "SHIPPED"
|
||||
? "This marks the shipment as outbound and should only happen after stock has been picked and packed from real inventory locations."
|
||||
: "This changes the logistics state used by related shipping and sales workflows.",
|
||||
recovery: "If the status is wrong, return the shipment to the correct state and confirm pick quantities still match the physical shipment.",
|
||||
confirmLabel: `Set ${label}`,
|
||||
confirmationLabel: nextStatus === "DELIVERED" ? "Type shipment number to confirm:" : undefined,
|
||||
confirmationValue: nextStatus === "DELIVERED" ? shipment.shipmentNumber : undefined,
|
||||
nextStatus,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOpenDocument(kind: "packing-slip" | "label" | "bol") {
|
||||
if (!token || !shipment) {
|
||||
return;
|
||||
@@ -102,19 +189,45 @@ export function ShipmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePostPick() {
|
||||
if (!token || !shipment || !selectedLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPostingPick(true);
|
||||
setStatus("Posting shipment pick and issuing stock...");
|
||||
try {
|
||||
const nextShipment = await api.postShipmentPick(token, shipment.id, {
|
||||
...pickForm,
|
||||
quantity: Number(pickForm.quantity),
|
||||
});
|
||||
setShipment(nextShipment);
|
||||
setPickForm(buildInitialPickForm(nextShipment, locationOptions));
|
||||
setStatus("Shipment pick posted. Inventory was issued from the selected stock location.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to post shipment pick.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsPostingPick(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shipment) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{shipment.shipmentNumber}</h3>
|
||||
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} · {shipment.customerName}</p>
|
||||
<div className="mt-3"><ShipmentStatusBadge status={shipment.status} /></div>
|
||||
<p className="section-kicker">SHIPMENT</p>
|
||||
<h3 className="module-title">{shipment.shipmentNumber}</h3>
|
||||
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} / {shipment.customerName}</p>
|
||||
<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 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>
|
||||
@@ -134,12 +247,12 @@ export function ShipmentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||
<p className="mt-2 text-sm text-muted">Update shipment status without opening the editor.</p>
|
||||
<p className="section-kicker">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{shipmentStatusOptions.map((option) => (
|
||||
@@ -151,46 +264,263 @@ export function ShipmentDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Carrier</p><div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Service</p><div className="mt-2 text-base font-bold text-text">{shipment.serviceLevel || "Not set"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tracking</p><div className="mt-2 text-base font-bold text-text">{shipment.trackingNumber || "Not set"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">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,1fr)_minmax(320px,0.9fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment Notes</p>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
|
||||
|
||||
<section className="grid gap-2 xl:grid-cols-4">
|
||||
<article className="surface-panel-tight">
|
||||
<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>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timing</p>
|
||||
<article className="surface-panel-tight">
|
||||
<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">
|
||||
<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><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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
|
||||
{canManage ? (
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">PICK AND ISSUE FROM STOCK</p>
|
||||
</div>
|
||||
<div className="rounded-[16px] border border-line/70 bg-page/60 px-2 py-2 text-xs text-muted">
|
||||
Select line, location, and quantity.
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<label className="flex flex-col gap-2 text-sm text-text">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment line</span>
|
||||
<select
|
||||
value={pickForm.salesOrderLineId}
|
||||
onChange={(event) => {
|
||||
const nextLine = shipment.lines.find((line) => line.salesOrderLineId === event.target.value) ?? null;
|
||||
setPickForm((current) => ({
|
||||
...current,
|
||||
salesOrderLineId: event.target.value,
|
||||
quantity: Math.min(nextLine?.remainingQuantity ?? 1, 1),
|
||||
}));
|
||||
}}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
{shipment.lines.map((line) => (
|
||||
<option key={line.salesOrderLineId} value={line.salesOrderLineId}>
|
||||
{line.itemSku} / remaining {line.remainingQuantity} {line.unitOfMeasure}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm text-text">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse</span>
|
||||
<select
|
||||
value={pickForm.warehouseId}
|
||||
onChange={(event) => {
|
||||
const nextWarehouseId = event.target.value;
|
||||
const nextLocation = locationOptions.find((location) => location.warehouseId === nextWarehouseId) ?? null;
|
||||
setPickForm((current) => ({
|
||||
...current,
|
||||
warehouseId: nextWarehouseId,
|
||||
locationId: nextLocation?.locationId ?? "",
|
||||
}));
|
||||
}}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
{warehouseOptions.map((warehouse) => (
|
||||
<option key={warehouse.id} value={warehouse.id}>
|
||||
{warehouse.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm text-text">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Location</span>
|
||||
<select
|
||||
value={pickForm.locationId}
|
||||
onChange={(event) => setPickForm((current) => ({ ...current, locationId: event.target.value }))}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
>
|
||||
{availableLocations.map((location) => (
|
||||
<option key={location.locationId} value={location.locationId}>
|
||||
{location.warehouseCode} / {location.locationCode} / {location.locationName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm text-text">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.0001}
|
||||
step="any"
|
||||
value={pickForm.quantity}
|
||||
onChange={(event) => setPickForm((current) => ({ ...current, quantity: Number(event.target.value) }))}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm text-text">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||
<input
|
||||
type="text"
|
||||
value={pickForm.notes}
|
||||
onChange={(event) => setPickForm((current) => ({ ...current, notes: event.target.value }))}
|
||||
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||
placeholder="Picker, carton, or handling notes"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="text-sm text-muted">
|
||||
{selectedLine
|
||||
? `Remaining on selected line: ${selectedLine.remainingQuantity} ${selectedLine.unitOfMeasure}.`
|
||||
: "Select a shipment line to issue inventory."}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePostPick()}
|
||||
disabled={
|
||||
isPostingPick ||
|
||||
!selectedLine ||
|
||||
selectedLine.remainingQuantity <= 0 ||
|
||||
!pickForm.warehouseId ||
|
||||
!pickForm.locationId ||
|
||||
pickForm.quantity <= 0
|
||||
}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isPostingPick ? "Issuing stock..." : "Post shipment pick"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||
<article className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">PICK HISTORY</p>
|
||||
</div>
|
||||
</div>
|
||||
{shipment.picks.length === 0 ? (
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||
No shipment picks posted yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{shipment.picks.map((pick) => (
|
||||
<div key={pick.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{pick.itemSku} / {pick.itemName}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{pick.quantity} issued from {pick.warehouseCode} / {pick.locationCode}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>{pick.createdByName}</div>
|
||||
<div className="mt-1">{formatDateTime(pick.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-text">{pick.notes || "No pick notes."}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">SHIPMENT NOTES</p>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Related Shipments</p>
|
||||
<p className="mt-2 text-sm text-muted">Other shipments already tied to this sales order.</p>
|
||||
<p className="section-kicker">RELATED SHIPMENTS</p>
|
||||
</div>
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
{relatedShipments.length === 0 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No additional shipments exist for this sales order.</div>
|
||||
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No additional shipments.</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
{relatedShipments.map((related) => (
|
||||
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<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>
|
||||
<ShipmentStatusBadge status={related.status} />
|
||||
</div>
|
||||
@@ -199,6 +529,7 @@ export function ShipmentDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FileAttachmentsPanel
|
||||
ownerType="SHIPMENT"
|
||||
ownerId={shipment.id}
|
||||
@@ -207,6 +538,31 @@ export function ShipmentDetailPage() {
|
||||
description="Store carrier paperwork, signed delivery records, bills of lading, and related logistics support files on the shipment record."
|
||||
emptyMessage="No logistics attachments have been uploaded for this shipment yet."
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={pendingConfirmation != null}
|
||||
title={pendingConfirmation?.title ?? "Confirm shipment action"}
|
||||
description={pendingConfirmation?.description ?? ""}
|
||||
impact={pendingConfirmation?.impact}
|
||||
recovery={pendingConfirmation?.recovery}
|
||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||
isConfirming={isUpdatingStatus}
|
||||
onClose={() => {
|
||||
if (!isUpdatingStatus) {
|
||||
setPendingConfirmation(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||
setPendingConfirmation(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js";
|
||||
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 { 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 (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<form className="page-stack" onSubmit={handleSubmit}>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Editor</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
|
||||
<p className="section-kicker">SHIPPING EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
|
||||
</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
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<section className="space-y-3 surface-panel">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Sales Order</span>
|
||||
<div className="relative">
|
||||
@@ -187,7 +191,7 @@ export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||
@@ -199,3 +203,4 @@ export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,11 @@ export function ShipmentListPage() {
|
||||
}, [searchTerm, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">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>
|
||||
<p className="section-kicker">SHIPPING</p>
|
||||
<h3 className="module-title">SHIPMENTS</h3>
|
||||
</div>
|
||||
{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">
|
||||
@@ -51,7 +50,7 @@ export function ShipmentListPage() {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
||||
<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">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||
<input
|
||||
@@ -76,11 +75,11 @@ export function ShipmentListPage() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<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 ? (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No shipments have been added yet.</div>
|
||||
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No shipments have been added yet.</div>
|
||||
) : (
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
@@ -114,3 +113,4 @@ export function ShipmentListPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
1864
client/src/modules/workbench/WorkbenchPage.tsx
Normal file
1864
client/src/modules/workbench/WorkbenchPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,16 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ThemeProvider } from "../theme/ThemeProvider";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
|
||||
describe("ThemeToggle", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute("style");
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
it("toggles the html dark class", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
@@ -16,5 +22,31 @@ describe("ThemeToggle", () => {
|
||||
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates persisted brand theme values on startup", async () => {
|
||||
window.localStorage.setItem(
|
||||
"mrp.theme.brand-profile",
|
||||
JSON.stringify({
|
||||
theme: {
|
||||
primaryColor: "#112233",
|
||||
accentColor: "#445566",
|
||||
surfaceColor: "#778899",
|
||||
fontFamily: "Manrope",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<div>Theme</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue("--color-brand")).toBe("17 34 51");
|
||||
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("68 85 102");
|
||||
expect(document.documentElement.style.getPropertyValue("--color-surface-brand")).toBe("119 136 153");
|
||||
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("Manrope");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,14 @@ interface ThemeContextValue {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
const storageKey = "mrp.theme.mode";
|
||||
const brandProfileKey = "mrp.theme.brand-profile";
|
||||
|
||||
function applyThemeVariables(profile: Pick<CompanyProfileDto, "theme">) {
|
||||
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
|
||||
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
|
||||
document.documentElement.style.setProperty("--color-surface-brand", hexToRgbTriplet(profile.theme.surfaceColor));
|
||||
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||
@@ -20,6 +28,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const storedBrandProfile = window.localStorage.getItem(brandProfileKey);
|
||||
if (!storedBrandProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(storedBrandProfile) as Pick<CompanyProfileDto, "theme">;
|
||||
applyThemeVariables(parsed);
|
||||
} catch {
|
||||
window.localStorage.removeItem(brandProfileKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||
document.documentElement.style.colorScheme = mode;
|
||||
@@ -31,10 +53,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
|
||||
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
|
||||
document.documentElement.style.setProperty("--color-surface-brand", hexToRgbTriplet(profile.theme.surfaceColor));
|
||||
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
|
||||
applyThemeVariables(profile);
|
||||
window.localStorage.setItem(brandProfileKey, JSON.stringify({ theme: profile.theme }));
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -4,6 +4,24 @@ import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("@svar-ui/react-gantt")) {
|
||||
return "gantt-vendor";
|
||||
}
|
||||
if (id.includes("@tanstack/react-query")) {
|
||||
return "query-vendor";
|
||||
}
|
||||
if (id.includes("react-router-dom") || id.includes("react-dom") || id.includes(`${path.sep}react${path.sep}`)) {
|
||||
return "react-vendor";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
@@ -16,4 +34,3 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
1
fabdash
Submodule
1
fabdash
Submodule
Submodule fabdash added at fe4d8b120c
@@ -0,0 +1,25 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthSession" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"revokedAt" DATETIME,
|
||||
"revokedById" TEXT,
|
||||
"revokedReason" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "AuthSession_revokedById_fkey" FOREIGN KEY ("revokedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthSession_userId_createdAt_idx" ON "AuthSession"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthSession_expiresAt_idx" ON "AuthSession"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthSession_revokedAt_idx" ON "AuthSession"("revokedAt");
|
||||
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditEvent" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"actorId" TEXT,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"summary" TEXT NOT NULL,
|
||||
"metadataJson" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "AuditEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEvent_entityType_entityId_createdAt_idx" ON "AuditEvent"("entityType", "entityId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEvent_actorId_createdAt_idx" ON "AuditEvent"("actorId", "createdAt");
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE "InventoryItem" ADD COLUMN "preferredVendorId" TEXT;
|
||||
|
||||
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderId" TEXT;
|
||||
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderLineId" TEXT;
|
||||
|
||||
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderId" TEXT;
|
||||
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderLineId" TEXT;
|
||||
|
||||
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
|
||||
CREATE INDEX "WorkOrder_salesOrderId_dueDate_idx" ON "WorkOrder"("salesOrderId", "dueDate");
|
||||
CREATE INDEX "WorkOrder_salesOrderLineId_dueDate_idx" ON "WorkOrder"("salesOrderLineId", "dueDate");
|
||||
CREATE INDEX "PurchaseOrderLine_salesOrderId_position_idx" ON "PurchaseOrderLine"("salesOrderId", "position");
|
||||
CREATE INDEX "PurchaseOrderLine_salesOrderLineId_position_idx" ON "PurchaseOrderLine"("salesOrderLineId", "position");
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseOrderRevision" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"purchaseOrderId" TEXT NOT NULL,
|
||||
"revisionNumber" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"snapshot" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "PurchaseOrderRevision_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "PurchaseOrderRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PurchaseOrderRevision_purchaseOrderId_revisionNumber_key" ON "PurchaseOrderRevision"("purchaseOrderId", "revisionNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PurchaseOrderRevision_purchaseOrderId_createdAt_idx" ON "PurchaseOrderRevision"("purchaseOrderId", "createdAt");
|
||||
@@ -0,0 +1,78 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InventorySkuFamily" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"code" TEXT NOT NULL,
|
||||
"sequenceCode" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"nextSequenceNumber" INTEGER NOT NULL DEFAULT 1,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InventorySkuNode" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"parentNodeId" TEXT,
|
||||
"code" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"level" INTEGER NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "InventorySkuNode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventorySkuNode_parentNodeId_fkey" FOREIGN KEY ("parentNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_InventoryItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sku" TEXT NOT NULL,
|
||||
"skuFamilyId" TEXT,
|
||||
"skuNodeId" TEXT,
|
||||
"skuSequenceNumber" INTEGER,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"unitOfMeasure" TEXT NOT NULL,
|
||||
"isSellable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isPurchasable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"preferredVendorId" TEXT,
|
||||
"defaultCost" REAL,
|
||||
"defaultPrice" REAL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "InventoryItem_preferredVendorId_fkey" FOREIGN KEY ("preferredVendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryItem_skuFamilyId_fkey" FOREIGN KEY ("skuFamilyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryItem_skuNodeId_fkey" FOREIGN KEY ("skuNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_InventoryItem" ("createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt") SELECT "createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt" FROM "InventoryItem";
|
||||
DROP TABLE "InventoryItem";
|
||||
ALTER TABLE "new_InventoryItem" RENAME TO "InventoryItem";
|
||||
CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
|
||||
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
|
||||
CREATE INDEX "InventoryItem_skuFamilyId_idx" ON "InventoryItem"("skuFamilyId");
|
||||
CREATE INDEX "InventoryItem_skuNodeId_idx" ON "InventoryItem"("skuNodeId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InventorySkuFamily_code_key" ON "InventorySkuFamily"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InventorySkuFamily_sequenceCode_key" ON "InventorySkuFamily"("sequenceCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InventorySkuNode_familyId_path_key" ON "InventorySkuNode"("familyId", "path");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InventorySkuNode_familyId_parentNodeId_sortOrder_idx" ON "InventorySkuNode"("familyId", "parentNodeId", "sortOrder");
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "ProjectMilestone" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"dueDate" DATETIME,
|
||||
"completedAt" DATETIME,
|
||||
"notes" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ProjectMilestone_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "ProjectMilestone_projectId_sortOrder_idx" ON "ProjectMilestone"("projectId", "sortOrder");
|
||||
CREATE INDEX "ProjectMilestone_projectId_dueDate_idx" ON "ProjectMilestone"("projectId", "dueDate");
|
||||
@@ -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';
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "WorkOrder" ADD COLUMN "holdReason" TEXT;
|
||||
@@ -18,17 +18,25 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userRoles UserRole[]
|
||||
authSessions AuthSession[] @relation("AuthSessionUser")
|
||||
revokedAuthSessions AuthSession[] @relation("AuthSessionRevokedBy")
|
||||
contactEntries CrmContactEntry[]
|
||||
inventoryTransactions InventoryTransaction[]
|
||||
purchaseReceipts PurchaseReceipt[]
|
||||
ownedProjects Project[] @relation("ProjectOwner")
|
||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||
workOrderCompletions WorkOrderCompletion[]
|
||||
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
|
||||
assignedWorkOrderOperations WorkOrderOperation[]
|
||||
shipmentPicks ShipmentPick[]
|
||||
financeCustomerPayments FinanceCustomerPayment[]
|
||||
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
|
||||
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
|
||||
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
|
||||
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
|
||||
purchaseOrderRevisionsCreated PurchaseOrderRevision[]
|
||||
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
|
||||
auditEvents AuditEvent[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
@@ -71,6 +79,26 @@ model RolePermission {
|
||||
@@id([roleId, permissionId])
|
||||
}
|
||||
|
||||
model AuthSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
revokedAt DateTime?
|
||||
revokedById String?
|
||||
revokedReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation("AuthSessionUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
revokedBy User? @relation("AuthSessionRevokedBy", fields: [revokedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([expiresAt])
|
||||
@@index([revokedAt])
|
||||
}
|
||||
|
||||
model CompanyProfile {
|
||||
id String @id @default(cuid())
|
||||
companyName String
|
||||
@@ -114,6 +142,9 @@ model FileAttachment {
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
sku String @unique
|
||||
skuFamilyId String?
|
||||
skuNodeId String?
|
||||
skuSequenceNumber Int?
|
||||
name String
|
||||
description String
|
||||
type String
|
||||
@@ -121,6 +152,7 @@ model InventoryItem {
|
||||
unitOfMeasure String
|
||||
isSellable Boolean @default(true)
|
||||
isPurchasable Boolean @default(true)
|
||||
preferredVendorId String?
|
||||
defaultCost Float?
|
||||
defaultPrice Float?
|
||||
notes String
|
||||
@@ -134,9 +166,53 @@ model InventoryItem {
|
||||
purchaseOrderLines PurchaseOrderLine[]
|
||||
workOrders WorkOrder[]
|
||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||
shipmentPicks ShipmentPick[]
|
||||
operations InventoryItemOperation[]
|
||||
reservations InventoryReservation[]
|
||||
transfers InventoryTransfer[]
|
||||
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
|
||||
skuFamily InventorySkuFamily? @relation(fields: [skuFamilyId], references: [id], onDelete: SetNull)
|
||||
skuNode InventorySkuNode? @relation(fields: [skuNodeId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([preferredVendorId])
|
||||
@@index([skuFamilyId])
|
||||
@@index([skuNodeId])
|
||||
}
|
||||
|
||||
model InventorySkuFamily {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
sequenceCode String @unique
|
||||
name String
|
||||
description String
|
||||
nextSequenceNumber Int @default(1)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
nodes InventorySkuNode[]
|
||||
items InventoryItem[]
|
||||
}
|
||||
|
||||
model InventorySkuNode {
|
||||
id String @id @default(cuid())
|
||||
familyId String
|
||||
parentNodeId String?
|
||||
code String
|
||||
label String
|
||||
description String
|
||||
path String
|
||||
level Int
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
family InventorySkuFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
parentNode InventorySkuNode? @relation("InventorySkuNodeTree", fields: [parentNodeId], references: [id], onDelete: Cascade)
|
||||
childNodes InventorySkuNode[] @relation("InventorySkuNodeTree")
|
||||
items InventoryItem[]
|
||||
|
||||
@@unique([familyId, path])
|
||||
@@index([familyId, parentNodeId, sortOrder])
|
||||
}
|
||||
|
||||
model Warehouse {
|
||||
@@ -151,6 +227,7 @@ model Warehouse {
|
||||
purchaseReceipts PurchaseReceipt[]
|
||||
workOrders WorkOrder[]
|
||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||
shipmentPicks ShipmentPick[]
|
||||
reservations InventoryReservation[]
|
||||
transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse")
|
||||
transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse")
|
||||
@@ -222,6 +299,7 @@ model WarehouseLocation {
|
||||
purchaseReceipts PurchaseReceipt[]
|
||||
workOrders WorkOrder[]
|
||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||
shipmentPicks ShipmentPick[]
|
||||
reservations InventoryReservation[]
|
||||
transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation")
|
||||
transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation")
|
||||
@@ -324,6 +402,8 @@ model Vendor {
|
||||
contactEntries CrmContactEntry[]
|
||||
contacts CrmContact[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
capexEntries CapexEntry[]
|
||||
preferredSupplyItems InventoryItem[]
|
||||
}
|
||||
|
||||
model CrmContactEntry {
|
||||
@@ -416,6 +496,9 @@ model SalesOrder {
|
||||
shipments Shipment[]
|
||||
projects Project[]
|
||||
revisions SalesOrderRevision[]
|
||||
workOrders WorkOrder[]
|
||||
purchaseOrderLines PurchaseOrderLine[]
|
||||
customerPayments FinanceCustomerPayment[]
|
||||
}
|
||||
|
||||
model SalesOrderLine {
|
||||
@@ -431,6 +514,9 @@ model SalesOrderLine {
|
||||
updatedAt DateTime @updatedAt
|
||||
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
||||
workOrders WorkOrder[]
|
||||
purchaseOrderLines PurchaseOrderLine[]
|
||||
shipmentPicks ShipmentPick[]
|
||||
|
||||
@@index([orderId, position])
|
||||
}
|
||||
@@ -482,10 +568,35 @@ model Shipment {
|
||||
updatedAt DateTime @updatedAt
|
||||
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
|
||||
projects Project[]
|
||||
picks ShipmentPick[]
|
||||
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
projectNumber String @unique
|
||||
@@ -507,20 +618,42 @@ model Project {
|
||||
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
|
||||
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
workOrders WorkOrder[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
milestones ProjectMilestone[]
|
||||
|
||||
@@index([customerId, createdAt])
|
||||
@@index([ownerId, dueDate])
|
||||
@@index([status, priority])
|
||||
}
|
||||
|
||||
model ProjectMilestone {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
title String
|
||||
status String
|
||||
dueDate DateTime?
|
||||
completedAt DateTime?
|
||||
notes String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([projectId, sortOrder])
|
||||
@@index([projectId, dueDate])
|
||||
}
|
||||
|
||||
model WorkOrder {
|
||||
id String @id @default(cuid())
|
||||
workOrderNumber String @unique
|
||||
itemId String
|
||||
projectId String?
|
||||
salesOrderId String?
|
||||
salesOrderLineId String?
|
||||
warehouseId String
|
||||
locationId String
|
||||
status String
|
||||
holdReason String?
|
||||
quantity Int
|
||||
completedQuantity Int @default(0)
|
||||
dueDate DateTime?
|
||||
@@ -529,15 +662,20 @@ model WorkOrder {
|
||||
updatedAt DateTime @updatedAt
|
||||
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
|
||||
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
|
||||
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
|
||||
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
||||
operations WorkOrderOperation[]
|
||||
materialIssues WorkOrderMaterialIssue[]
|
||||
completions WorkOrderCompletion[]
|
||||
reservations InventoryReservation[]
|
||||
financeCostSnapshot FinanceManufacturingCostSnapshot?
|
||||
|
||||
@@index([itemId, createdAt])
|
||||
@@index([projectId, dueDate])
|
||||
@@index([salesOrderId, dueDate])
|
||||
@@index([salesOrderLineId, dueDate])
|
||||
@@index([status, dueDate])
|
||||
@@index([warehouseId, createdAt])
|
||||
}
|
||||
@@ -548,6 +686,9 @@ model ManufacturingStation {
|
||||
name String
|
||||
description String
|
||||
queueDays Int @default(0)
|
||||
dailyCapacityMinutes Int @default(480)
|
||||
parallelCapacity Int @default(1)
|
||||
workingDays String @default("1,2,3,4,5")
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -577,6 +718,7 @@ model WorkOrderOperation {
|
||||
id String @id @default(cuid())
|
||||
workOrderId String
|
||||
stationId String
|
||||
assignedOperatorId String?
|
||||
sequence Int
|
||||
setupMinutes Int @default(0)
|
||||
runMinutesPerUnit Int @default(0)
|
||||
@@ -585,13 +727,36 @@ model WorkOrderOperation {
|
||||
plannedStart DateTime
|
||||
plannedEnd DateTime
|
||||
notes String
|
||||
status String @default("PENDING")
|
||||
actualStart DateTime?
|
||||
actualEnd DateTime?
|
||||
actualMinutes Int @default(0)
|
||||
activeTimerStartedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
|
||||
assignedOperator User? @relation(fields: [assignedOperatorId], references: [id], onDelete: SetNull)
|
||||
laborEntries WorkOrderOperationLaborEntry[]
|
||||
|
||||
@@index([workOrderId, sequence])
|
||||
@@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 {
|
||||
@@ -629,10 +794,79 @@ model WorkOrderCompletion {
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
documentNumber String @unique
|
||||
vendorId String
|
||||
projectId String?
|
||||
status String
|
||||
issueDate DateTime
|
||||
taxPercent Float @default(0)
|
||||
@@ -641,14 +875,21 @@ model PurchaseOrder {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
lines PurchaseOrderLine[]
|
||||
receipts PurchaseReceipt[]
|
||||
revisions PurchaseOrderRevision[]
|
||||
capexEntries CapexEntry[]
|
||||
|
||||
@@index([projectId, issueDate])
|
||||
}
|
||||
|
||||
model PurchaseOrderLine {
|
||||
id String @id @default(cuid())
|
||||
purchaseOrderId String
|
||||
itemId String
|
||||
salesOrderId String?
|
||||
salesOrderLineId String?
|
||||
description String
|
||||
quantity Int
|
||||
unitOfMeasure String
|
||||
@@ -658,9 +899,13 @@ model PurchaseOrderLine {
|
||||
updatedAt DateTime @updatedAt
|
||||
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||||
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
||||
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
|
||||
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
|
||||
receiptLines PurchaseReceiptLine[]
|
||||
|
||||
@@index([purchaseOrderId, position])
|
||||
@@index([salesOrderId, position])
|
||||
@@index([salesOrderLineId, position])
|
||||
}
|
||||
|
||||
model PurchaseReceipt {
|
||||
@@ -698,3 +943,35 @@ model PurchaseReceiptLine {
|
||||
@@index([purchaseReceiptId])
|
||||
@@index([purchaseOrderLineId])
|
||||
}
|
||||
|
||||
model PurchaseOrderRevision {
|
||||
id String @id @default(cuid())
|
||||
purchaseOrderId String
|
||||
revisionNumber Int
|
||||
reason String
|
||||
snapshot String
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([purchaseOrderId, revisionNumber])
|
||||
@@index([purchaseOrderId, createdAt])
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id String @id @default(cuid())
|
||||
actorId String?
|
||||
entityType String
|
||||
entityId String?
|
||||
action String
|
||||
summary String
|
||||
metadataJson String
|
||||
createdAt DateTime @default(now())
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([entityType, entityId, createdAt])
|
||||
@@index([actorId, createdAt])
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@ import pinoHttp from "pino-http";
|
||||
import { env } from "./config/env.js";
|
||||
import { paths } from "./config/paths.js";
|
||||
import { verifyToken } from "./lib/auth.js";
|
||||
import { getActiveAuthSession, touchAuthSession } from "./lib/auth-sessions.js";
|
||||
import { getCurrentUserById } from "./lib/current-user.js";
|
||||
import { fail, ok } from "./lib/http.js";
|
||||
import { recordSupportLog } from "./lib/support-log.js";
|
||||
import { adminRouter } from "./modules/admin/router.js";
|
||||
import { authRouter } from "./modules/auth/router.js";
|
||||
import { crmRouter } from "./modules/crm/router.js";
|
||||
import { documentsRouter } from "./modules/documents/router.js";
|
||||
import { filesRouter } from "./modules/files/router.js";
|
||||
import { financeRouter } from "./modules/finance/router.js";
|
||||
import { ganttRouter } from "./modules/gantt/router.js";
|
||||
import { inventoryRouter } from "./modules/inventory/router.js";
|
||||
import { manufacturingRouter } from "./modules/manufacturing/router.js";
|
||||
@@ -42,19 +46,59 @@ export function createApp() {
|
||||
try {
|
||||
const token = authHeader.slice("Bearer ".length);
|
||||
const payload = verifyToken(token);
|
||||
const session = await getActiveAuthSession(payload.sid, payload.sub);
|
||||
if (!session) {
|
||||
request.authUser = undefined;
|
||||
request.authSessionId = undefined;
|
||||
return next();
|
||||
}
|
||||
const authUser = await getCurrentUserById(payload.sub);
|
||||
request.authUser = authUser ?? undefined;
|
||||
if (!authUser) {
|
||||
request.authUser = undefined;
|
||||
request.authSessionId = undefined;
|
||||
return next();
|
||||
}
|
||||
|
||||
request.authUser = authUser;
|
||||
request.authSessionId = session.id;
|
||||
void touchAuthSession(session.id).catch(() => undefined);
|
||||
} catch {
|
||||
request.authUser = undefined;
|
||||
request.authSessionId = undefined;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((request, response, next) => {
|
||||
response.on("finish", () => {
|
||||
if (response.locals.supportLogRecorded || response.statusCode < 400 || request.path === "/api/v1/health") {
|
||||
return;
|
||||
}
|
||||
|
||||
recordSupportLog({
|
||||
level: response.statusCode >= 500 ? "ERROR" : "WARN",
|
||||
source: "http-response",
|
||||
message: `${request.method} ${request.originalUrl} returned ${response.statusCode}.`,
|
||||
context: {
|
||||
method: request.method,
|
||||
path: request.originalUrl,
|
||||
statusCode: response.statusCode,
|
||||
actorId: request.authUser?.id ?? null,
|
||||
ip: request.ip,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
|
||||
app.use("/api/v1/auth", authRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
app.use("/api/v1", settingsRouter);
|
||||
app.use("/api/v1/files", filesRouter);
|
||||
app.use("/api/v1/finance", financeRouter);
|
||||
app.use("/api/v1/crm", crmRouter);
|
||||
app.use("/api/v1/inventory", inventoryRouter);
|
||||
app.use("/api/v1/manufacturing", manufacturingRouter);
|
||||
@@ -72,7 +116,19 @@ export function createApp() {
|
||||
});
|
||||
}
|
||||
|
||||
app.use((error: Error, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
app.use((error: Error, request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
response.locals.supportLogRecorded = true;
|
||||
recordSupportLog({
|
||||
level: "ERROR",
|
||||
source: "express-error",
|
||||
message: error.message || "Unexpected server error.",
|
||||
context: {
|
||||
method: request.method,
|
||||
path: request.originalUrl,
|
||||
actorId: request.authUser?.id ?? null,
|
||||
stack: error.stack ?? null,
|
||||
},
|
||||
});
|
||||
return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error.");
|
||||
});
|
||||
|
||||
|
||||
27
server/src/lib/audit.ts
Normal file
27
server/src/lib/audit.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
type AuditClient = Prisma.TransactionClient | typeof prisma;
|
||||
|
||||
interface LogAuditEventInput {
|
||||
actorId?: string | null;
|
||||
entityType: string;
|
||||
entityId?: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function logAuditEvent(input: LogAuditEventInput, client: AuditClient = prisma) {
|
||||
await client.auditEvent.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId ?? null,
|
||||
action: input.action,
|
||||
summary: input.summary,
|
||||
metadataJson: JSON.stringify(input.metadata ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
100
server/src/lib/auth-sessions.ts
Normal file
100
server/src/lib/auth-sessions.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
|
||||
const SESSION_RETENTION_DAYS = 30;
|
||||
|
||||
export interface AuthSessionContext {
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export function getSessionExpiryDate(now = new Date()) {
|
||||
return new Date(now.getTime() + SESSION_DURATION_MS);
|
||||
}
|
||||
|
||||
export function getSessionRetentionCutoff(now = new Date()) {
|
||||
return new Date(now.getTime() - SESSION_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export async function createAuthSession(input: { userId: string; ipAddress?: string | null; userAgent?: string | null }) {
|
||||
return prisma.authSession.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
expiresAt: getSessionExpiryDate(),
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getActiveAuthSession(sessionId: string, userId: string): Promise<AuthSessionContext | null> {
|
||||
const session = await prisma.authSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
userId,
|
||||
revokedAt: null,
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function touchAuthSession(sessionId: string) {
|
||||
await prisma.authSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId: string, input: { revokedById?: string | null; reason: string }) {
|
||||
return prisma.authSession.updateMany({
|
||||
where: {
|
||||
id: sessionId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
revokedById: input.revokedById ?? null,
|
||||
revokedReason: input.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function pruneOldAuthSessions() {
|
||||
const cutoff = getSessionRetentionCutoff();
|
||||
|
||||
const result = await prisma.authSession.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
revokedAt: {
|
||||
lt: cutoff,
|
||||
},
|
||||
},
|
||||
{
|
||||
revokedAt: null,
|
||||
expiresAt: {
|
||||
lt: cutoff,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
@@ -5,14 +5,16 @@ import { env } from "../config/env.js";
|
||||
|
||||
interface AuthTokenPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
email: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export function signToken(user: AuthUser) {
|
||||
export function signToken(user: AuthUser, sessionId: string) {
|
||||
return jwt.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
sid: sessionId,
|
||||
email: user.email,
|
||||
permissions: user.permissions,
|
||||
} satisfies AuthTokenPayload,
|
||||
@@ -24,4 +26,3 @@ export function signToken(user: AuthUser) {
|
||||
export function verifyToken(token: string) {
|
||||
return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ const permissionDescriptions: Record<PermissionKey, string> = {
|
||||
[permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data",
|
||||
[permissions.filesRead]: "View 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.salesWrite]: "Manage quotes and sales orders",
|
||||
[permissions.projectsRead]: "View projects and program records",
|
||||
@@ -123,151 +125,10 @@ export async function bootstrapAppData() {
|
||||
});
|
||||
}
|
||||
|
||||
if ((await prisma.customer.count()) === 0) {
|
||||
await prisma.customer.createMany({
|
||||
data: [
|
||||
{
|
||||
name: "Acme Components",
|
||||
email: "buyer@acme.example",
|
||||
phone: "555-0101",
|
||||
addressLine1: "1 Industrial Road",
|
||||
addressLine2: "",
|
||||
city: "Detroit",
|
||||
state: "MI",
|
||||
postalCode: "48201",
|
||||
country: "USA",
|
||||
notes: "Priority account",
|
||||
},
|
||||
{
|
||||
name: "Northwind Fabrication",
|
||||
email: "ops@northwind.example",
|
||||
phone: "555-0120",
|
||||
addressLine1: "42 Assembly Ave",
|
||||
addressLine2: "",
|
||||
city: "Milwaukee",
|
||||
state: "WI",
|
||||
postalCode: "53202",
|
||||
country: "USA",
|
||||
notes: "Requires ASN notice",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if ((await prisma.vendor.count()) === 0) {
|
||||
await prisma.vendor.create({
|
||||
data: {
|
||||
name: "SteelSource Supply",
|
||||
email: "sales@steelsource.example",
|
||||
phone: "555-0142",
|
||||
addressLine1: "77 Mill Street",
|
||||
addressLine2: "",
|
||||
city: "Gary",
|
||||
state: "IN",
|
||||
postalCode: "46402",
|
||||
country: "USA",
|
||||
notes: "Lead time 5 business days",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ((await prisma.inventoryItem.count()) === 0) {
|
||||
const plate = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
sku: "RM-PLATE-AL-125",
|
||||
name: "Aluminum Plate 1/8in",
|
||||
description: "Raw aluminum plate stock for fabricated assemblies.",
|
||||
type: "PURCHASED",
|
||||
status: "ACTIVE",
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: false,
|
||||
isPurchasable: true,
|
||||
defaultCost: 42.5,
|
||||
defaultPrice: null,
|
||||
notes: "Primary sheet stock for enclosure fabrication.",
|
||||
},
|
||||
});
|
||||
|
||||
const fastener = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
sku: "HW-SCREW-832",
|
||||
name: "8-32 Socket Head Screw",
|
||||
description: "Standard socket head cap screw for enclosure assemblies.",
|
||||
type: "PURCHASED",
|
||||
status: "ACTIVE",
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: false,
|
||||
isPurchasable: true,
|
||||
defaultCost: 0.18,
|
||||
defaultPrice: null,
|
||||
notes: "Bulk hardware item.",
|
||||
},
|
||||
});
|
||||
|
||||
const assembly = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
sku: "FG-CTRL-BASE",
|
||||
name: "Control Base Assembly",
|
||||
description: "Base enclosure assembly for standard control packages.",
|
||||
type: "ASSEMBLY",
|
||||
status: "ACTIVE",
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: true,
|
||||
isPurchasable: false,
|
||||
defaultCost: 118,
|
||||
defaultPrice: 249,
|
||||
notes: "Starter BOM for the inventory foundation slice.",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.inventoryBomLine.createMany({
|
||||
data: [
|
||||
{
|
||||
parentItemId: assembly.id,
|
||||
componentItemId: plate.id,
|
||||
quantity: 2,
|
||||
unitOfMeasure: "EA",
|
||||
notes: "Side panel blanks",
|
||||
position: 10,
|
||||
},
|
||||
{
|
||||
parentItemId: assembly.id,
|
||||
componentItemId: fastener.id,
|
||||
quantity: 12,
|
||||
unitOfMeasure: "EA",
|
||||
notes: "General assembly hardware",
|
||||
position: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if ((await prisma.warehouse.count()) === 0) {
|
||||
await prisma.warehouse.create({
|
||||
data: {
|
||||
code: "MAIN",
|
||||
name: "Main Warehouse",
|
||||
notes: "Primary stocking location for finished goods and purchased materials.",
|
||||
locations: {
|
||||
create: [
|
||||
{
|
||||
code: "RECV",
|
||||
name: "Receiving",
|
||||
notes: "Initial inbound inspection and receipt staging.",
|
||||
},
|
||||
{
|
||||
code: "STOCK-A1",
|
||||
name: "Aisle A1",
|
||||
notes: "General rack storage for standard material.",
|
||||
},
|
||||
{
|
||||
code: "FG-STAGE",
|
||||
name: "Finished Goods Staging",
|
||||
notes: "Outbound-ready finished assemblies.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
const existingFinanceProfile = await prisma.financeProfile.findFirst();
|
||||
if (!existingFinanceProfile) {
|
||||
await prisma.financeProfile.create({
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissionKeys = new Set<PermissionKey>();
|
||||
const roleNames = user.userRoles.map(({ role }) => {
|
||||
for (const rolePermission of role.rolePermissions) {
|
||||
@@ -44,4 +48,3 @@ export async function getCurrentUserById(userId: string): Promise<AuthUser | nul
|
||||
permissions: [...permissionKeys],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import puppeteer from "puppeteer";
|
||||
import puppeteer, { PaperFormat } from "puppeteer";
|
||||
|
||||
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({
|
||||
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
|
||||
headless: true,
|
||||
@@ -14,7 +21,10 @@ export async function renderPdf(html: string) {
|
||||
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||
|
||||
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,
|
||||
preferCSSPageSize: true,
|
||||
});
|
||||
|
||||
19
server/src/lib/startup-state.ts
Normal file
19
server/src/lib/startup-state.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StartupValidationReportDto } from "@mrp/shared";
|
||||
|
||||
let latestStartupReport: StartupValidationReportDto = {
|
||||
status: "WARN",
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
durationMs: 0,
|
||||
passCount: 0,
|
||||
warnCount: 0,
|
||||
failCount: 0,
|
||||
checks: [],
|
||||
};
|
||||
|
||||
export function setLatestStartupReport(report: StartupValidationReportDto) {
|
||||
latestStartupReport = report;
|
||||
}
|
||||
|
||||
export function getLatestStartupReport() {
|
||||
return latestStartupReport;
|
||||
}
|
||||
183
server/src/lib/startup-validation.ts
Normal file
183
server/src/lib/startup-validation.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { StartupValidationCheckDto, StartupValidationReportDto } from "@mrp/shared";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { env } from "../config/env.js";
|
||||
import { paths } from "../config/paths.js";
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
async function pathExists(targetPath: string) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function canWritePath(targetPath: string) {
|
||||
try {
|
||||
await fs.access(targetPath, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectStartupValidationReport(): Promise<StartupValidationReportDto> {
|
||||
const startedAt = Date.now();
|
||||
const checks: StartupValidationCheckDto[] = [];
|
||||
const dataDirExists = await pathExists(paths.dataDir);
|
||||
const uploadsDirExists = await pathExists(paths.uploadsDir);
|
||||
const prismaDirExists = await pathExists(paths.prismaDir);
|
||||
const databaseFilePath = path.join(paths.prismaDir, "app.db");
|
||||
const databaseFileExists = await pathExists(databaseFilePath);
|
||||
const clientBundlePath = path.join(paths.clientDistDir, "index.html");
|
||||
const clientBundleExists = await pathExists(clientBundlePath);
|
||||
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
|
||||
const puppeteerExists = await pathExists(puppeteerPath);
|
||||
const dataDirWritable = dataDirExists && (await canWritePath(paths.dataDir));
|
||||
const uploadsDirWritable = uploadsDirExists && (await canWritePath(paths.uploadsDir));
|
||||
|
||||
checks.push({
|
||||
id: "data-dir",
|
||||
label: "Data directory",
|
||||
status: dataDirExists ? "PASS" : "FAIL",
|
||||
message: dataDirExists ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "uploads-dir",
|
||||
label: "Uploads directory",
|
||||
status: uploadsDirExists ? "PASS" : "FAIL",
|
||||
message: uploadsDirExists ? `Uploads directory available at ${paths.uploadsDir}.` : `Uploads directory is missing: ${paths.uploadsDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "prisma-dir",
|
||||
label: "Prisma directory",
|
||||
status: prismaDirExists ? "PASS" : "FAIL",
|
||||
message: prismaDirExists ? `Prisma data directory available at ${paths.prismaDir}.` : `Prisma data directory is missing: ${paths.prismaDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "database-file",
|
||||
label: "Database file",
|
||||
status: databaseFileExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
|
||||
message: databaseFileExists ? `SQLite database file found at ${databaseFilePath}.` : `SQLite database file is missing: ${databaseFilePath}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "data-dir-write",
|
||||
label: "Data directory writable",
|
||||
status: dataDirWritable ? "PASS" : "FAIL",
|
||||
message: dataDirWritable ? `Application can write to ${paths.dataDir}.` : `Application cannot write to ${paths.dataDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "uploads-dir-write",
|
||||
label: "Uploads directory writable",
|
||||
status: uploadsDirWritable ? "PASS" : "FAIL",
|
||||
message: uploadsDirWritable ? `Application can write to ${paths.uploadsDir}.` : `Application cannot write to ${paths.uploadsDir}.`,
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.$queryRawUnsafe("SELECT 1");
|
||||
checks.push({
|
||||
id: "database-connection",
|
||||
label: "Database connection",
|
||||
status: "PASS",
|
||||
message: "SQLite connection check succeeded.",
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: "database-connection",
|
||||
label: "Database connection",
|
||||
status: "FAIL",
|
||||
message: error instanceof Error ? error.message : "SQLite connection check failed.",
|
||||
});
|
||||
}
|
||||
|
||||
if (env.NODE_ENV === "production") {
|
||||
checks.push({
|
||||
id: "client-dist",
|
||||
label: "Client bundle",
|
||||
status: clientBundleExists ? "PASS" : "FAIL",
|
||||
message: clientBundleExists ? `Client bundle found at ${paths.clientDistDir}.` : `Production client bundle is missing from ${paths.clientDistDir}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "client-dist",
|
||||
label: "Client bundle",
|
||||
status: "PASS",
|
||||
message: "Client bundle check skipped outside production mode.",
|
||||
});
|
||||
}
|
||||
|
||||
checks.push({
|
||||
id: "puppeteer-runtime",
|
||||
label: "PDF runtime",
|
||||
status: puppeteerExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
|
||||
message: puppeteerExists
|
||||
? `Chromium runtime available at ${puppeteerPath}.`
|
||||
: `Chromium runtime was not found at ${puppeteerPath}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "client-origin",
|
||||
label: "Client origin",
|
||||
status: env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost") ? "WARN" : "PASS",
|
||||
message:
|
||||
env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost")
|
||||
? `Production CLIENT_ORIGIN still points to localhost: ${env.CLIENT_ORIGIN}.`
|
||||
: `Client origin is configured as ${env.CLIENT_ORIGIN}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "jwt-secret",
|
||||
label: "JWT secret",
|
||||
status: env.NODE_ENV === "production" && env.JWT_SECRET === "change-me" ? "WARN" : "PASS",
|
||||
message:
|
||||
env.NODE_ENV === "production" && env.JWT_SECRET === "change-me"
|
||||
? "Production is still using the default JWT secret."
|
||||
: "JWT secret is not using the default production value.",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "admin-password",
|
||||
label: "Bootstrap admin password",
|
||||
status: env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!" ? "WARN" : "PASS",
|
||||
message:
|
||||
env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!"
|
||||
? "Production is still using the default bootstrap admin password."
|
||||
: "Bootstrap admin credentials are not using the default production password.",
|
||||
});
|
||||
|
||||
const status = checks.some((check) => check.status === "FAIL")
|
||||
? "FAIL"
|
||||
: checks.some((check) => check.status === "WARN")
|
||||
? "WARN"
|
||||
: "PASS";
|
||||
|
||||
return {
|
||||
status,
|
||||
generatedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
passCount: checks.filter((check) => check.status === "PASS").length,
|
||||
warnCount: checks.filter((check) => check.status === "WARN").length,
|
||||
failCount: checks.filter((check) => check.status === "FAIL").length,
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertStartupReadiness() {
|
||||
const report = await collectStartupValidationReport();
|
||||
|
||||
if (report.status === "FAIL") {
|
||||
const failedChecks = report.checks.filter((check) => check.status === "FAIL").map((check) => `${check.label}: ${check.message}`);
|
||||
throw new Error(`Startup validation failed. ${failedChecks.join(" | ")}`);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
139
server/src/lib/support-log.ts
Normal file
139
server/src/lib/support-log.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto, SupportLogSummaryDto } from "@mrp/shared";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const SUPPORT_LOG_LIMIT = 500;
|
||||
const SUPPORT_LOG_RETENTION_DAYS = 14;
|
||||
|
||||
const supportLogs: SupportLogEntryDto[] = [];
|
||||
|
||||
function serializeContext(context?: Record<string, unknown>) {
|
||||
if (!context) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(context);
|
||||
} catch {
|
||||
return JSON.stringify({ serializationError: "Unable to serialize support log context." });
|
||||
}
|
||||
}
|
||||
|
||||
function getRetentionCutoff(now = new Date()) {
|
||||
return new Date(now.getTime() - SUPPORT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function pruneSupportLogs(now = new Date()) {
|
||||
const cutoff = getRetentionCutoff(now).getTime();
|
||||
const retained = supportLogs.filter((entry) => new Date(entry.createdAt).getTime() >= cutoff);
|
||||
supportLogs.length = 0;
|
||||
supportLogs.push(...retained.slice(0, SUPPORT_LOG_LIMIT));
|
||||
}
|
||||
|
||||
function normalizeFilters(filters?: SupportLogFiltersDto): SupportLogFiltersDto {
|
||||
return {
|
||||
level: filters?.level,
|
||||
source: filters?.source?.trim() || undefined,
|
||||
query: filters?.query?.trim() || undefined,
|
||||
start: filters?.start,
|
||||
end: filters?.end,
|
||||
limit: filters?.limit,
|
||||
};
|
||||
}
|
||||
|
||||
function filterSupportLogs(filters?: SupportLogFiltersDto) {
|
||||
pruneSupportLogs();
|
||||
|
||||
const normalized = normalizeFilters(filters);
|
||||
const startMs = normalized.start ? new Date(normalized.start).getTime() : null;
|
||||
const endMs = normalized.end ? new Date(normalized.end).getTime() : null;
|
||||
const query = normalized.query?.toLowerCase();
|
||||
const limit = Math.max(0, Math.min(normalized.limit ?? 100, SUPPORT_LOG_LIMIT));
|
||||
|
||||
return supportLogs
|
||||
.filter((entry) => {
|
||||
if (normalized.level && entry.level !== normalized.level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.source && entry.source !== normalized.source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const createdAtMs = new Date(entry.createdAt).getTime();
|
||||
if (startMs != null && createdAtMs < startMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (endMs != null && createdAtMs > endMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [entry.source, entry.message, entry.contextJson].some((value) => value.toLowerCase().includes(query));
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function buildSupportLogSummary(entries: SupportLogEntryDto[], totalCount: number, availableSources: string[]): SupportLogSummaryDto {
|
||||
return {
|
||||
totalCount,
|
||||
filteredCount: entries.length,
|
||||
sourceCount: availableSources.length,
|
||||
retentionDays: SUPPORT_LOG_RETENTION_DAYS,
|
||||
oldestEntryAt: entries.length > 0 ? entries[entries.length - 1]?.createdAt ?? null : null,
|
||||
newestEntryAt: entries.length > 0 ? entries[0]?.createdAt ?? null : null,
|
||||
levelCounts: {
|
||||
INFO: entries.filter((entry) => entry.level === "INFO").length,
|
||||
WARN: entries.filter((entry) => entry.level === "WARN").length,
|
||||
ERROR: entries.filter((entry) => entry.level === "ERROR").length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordSupportLog(entry: {
|
||||
level: SupportLogEntryDto["level"];
|
||||
source: string;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}) {
|
||||
pruneSupportLogs();
|
||||
|
||||
supportLogs.unshift({
|
||||
id: randomUUID(),
|
||||
level: entry.level,
|
||||
source: entry.source,
|
||||
message: entry.message,
|
||||
contextJson: serializeContext(entry.context),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (supportLogs.length > SUPPORT_LOG_LIMIT) {
|
||||
supportLogs.length = SUPPORT_LOG_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
export function listSupportLogs(filters?: SupportLogFiltersDto): SupportLogListDto {
|
||||
pruneSupportLogs();
|
||||
const normalized = normalizeFilters(filters);
|
||||
const availableSources = [...new Set(supportLogs.map((entry) => entry.source))].sort();
|
||||
const entries = filterSupportLogs(normalized);
|
||||
|
||||
return {
|
||||
entries,
|
||||
summary: buildSupportLogSummary(entries, supportLogs.length, availableSources),
|
||||
availableSources,
|
||||
filters: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSupportLogCount() {
|
||||
pruneSupportLogs();
|
||||
return supportLogs.length;
|
||||
}
|
||||
|
||||
export function getSupportLogRetentionDays() {
|
||||
return SUPPORT_LOG_RETENTION_DAYS;
|
||||
}
|
||||
173
server/src/modules/admin/router.ts
Normal file
173
server/src/modules/admin/router.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import {
|
||||
createAdminRole,
|
||||
listAdminAuthSessions,
|
||||
createAdminUser,
|
||||
getBackupGuidance,
|
||||
getAdminDiagnostics,
|
||||
getSupportLogs,
|
||||
getSupportSnapshot,
|
||||
listAdminPermissions,
|
||||
listAdminRoles,
|
||||
listAdminUsers,
|
||||
revokeAdminAuthSession,
|
||||
updateAdminRole,
|
||||
updateAdminUser,
|
||||
} from "./service.js";
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
const roleSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string(),
|
||||
permissionKeys: z.array(z.string().trim().min(1)),
|
||||
});
|
||||
|
||||
const userSchema = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string().trim().min(1).max(120),
|
||||
lastName: z.string().trim().min(1).max(120),
|
||||
isActive: z.boolean(),
|
||||
roleIds: z.array(z.string().trim().min(1)),
|
||||
password: z.string().min(8).nullable(),
|
||||
});
|
||||
|
||||
const supportLogQuerySchema = z.object({
|
||||
level: z.enum(["INFO", "WARN", "ERROR"]).optional(),
|
||||
source: z.string().trim().min(1).optional(),
|
||||
query: z.string().trim().optional(),
|
||||
start: z.string().datetime().optional(),
|
||||
end: z.string().datetime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await getAdminDiagnostics());
|
||||
});
|
||||
|
||||
adminRouter.get("/backup-guidance", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, getBackupGuidance());
|
||||
});
|
||||
|
||||
adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
const parsed = supportLogQuerySchema.safeParse(_request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Support snapshot filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await getSupportSnapshot(parsed.data));
|
||||
});
|
||||
|
||||
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const parsed = supportLogQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Support log filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, getSupportLogs(parsed.data));
|
||||
});
|
||||
|
||||
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await listAdminPermissions());
|
||||
});
|
||||
|
||||
adminRouter.get("/roles", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await listAdminRoles());
|
||||
});
|
||||
|
||||
adminRouter.post("/roles", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const parsed = roleSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createAdminRole(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.role, 201);
|
||||
});
|
||||
|
||||
adminRouter.put("/roles/:roleId", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const roleId = getRouteParam(request.params.roleId);
|
||||
if (!roleId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Role id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = roleSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateAdminRole(roleId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.role);
|
||||
});
|
||||
|
||||
adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await listAdminUsers());
|
||||
});
|
||||
|
||||
adminRouter.get("/sessions", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
return ok(response, await listAdminAuthSessions(request.authSessionId));
|
||||
});
|
||||
|
||||
adminRouter.post("/sessions/:sessionId/revoke", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const sessionId = getRouteParam(request.params.sessionId);
|
||||
if (!sessionId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Session id is invalid.");
|
||||
}
|
||||
|
||||
const result = await revokeAdminAuthSession(sessionId, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, { success: true as const });
|
||||
});
|
||||
|
||||
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const parsed = userSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createAdminUser(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.user, 201);
|
||||
});
|
||||
|
||||
adminRouter.put("/users/:userId", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||
const userId = getRouteParam(request.params.userId);
|
||||
if (!userId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "User id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = userSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateAdminUser(userId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.user);
|
||||
});
|
||||
902
server/src/modules/admin/service.ts
Normal file
902
server/src/modules/admin/service.ts
Normal file
@@ -0,0 +1,902 @@
|
||||
import type {
|
||||
AdminDiagnosticsDto,
|
||||
AdminAuthSessionDto,
|
||||
BackupGuidanceDto,
|
||||
AdminPermissionOptionDto,
|
||||
AdminRoleDto,
|
||||
AdminRoleInput,
|
||||
AdminUserDto,
|
||||
AdminUserInput,
|
||||
SupportSnapshotDto,
|
||||
AuditEventDto,
|
||||
SupportLogEntryDto,
|
||||
SupportLogFiltersDto,
|
||||
SupportLogListDto,
|
||||
} from "@mrp/shared";
|
||||
|
||||
import { env } from "../../config/env.js";
|
||||
import { paths } from "../../config/paths.js";
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { hashPassword } from "../../lib/password.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { getLatestStartupReport } from "../../lib/startup-state.js";
|
||||
import { getSupportLogCount, getSupportLogRetentionDays, listSupportLogs } from "../../lib/support-log.js";
|
||||
|
||||
function mapAuditEvent(record: {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
metadataJson: string;
|
||||
createdAt: Date;
|
||||
actor: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} | null;
|
||||
}): AuditEventDto {
|
||||
return {
|
||||
id: record.id,
|
||||
actorId: record.actorId,
|
||||
actorName: record.actor ? `${record.actor.firstName} ${record.actor.lastName}`.trim() : null,
|
||||
entityType: record.entityType,
|
||||
entityId: record.entityId,
|
||||
action: record.action,
|
||||
summary: record.summary,
|
||||
metadataJson: record.metadataJson,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
|
||||
return { ...record };
|
||||
}
|
||||
|
||||
function mapSupportLogList(record: SupportLogListDto): SupportLogListDto {
|
||||
return {
|
||||
entries: record.entries.map(mapSupportLogEntry),
|
||||
summary: record.summary,
|
||||
availableSources: record.availableSources,
|
||||
filters: record.filters,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRole(record: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
rolePermissions: Array<{
|
||||
permission: {
|
||||
key: string;
|
||||
};
|
||||
}>;
|
||||
_count: {
|
||||
userRoles: number;
|
||||
};
|
||||
}): AdminRoleDto {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
permissionKeys: record.rolePermissions.map((rolePermission) => rolePermission.permission.key).sort(),
|
||||
userCount: record._count.userRoles,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapUser(record: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userRoles: Array<{
|
||||
role: {
|
||||
id: string;
|
||||
name: string;
|
||||
rolePermissions: Array<{
|
||||
permission: {
|
||||
key: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}): AdminUserDto {
|
||||
const permissionKeys = new Set<string>();
|
||||
for (const userRole of record.userRoles) {
|
||||
for (const rolePermission of userRole.role.rolePermissions) {
|
||||
permissionKeys.add(rolePermission.permission.key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
email: record.email,
|
||||
firstName: record.firstName,
|
||||
lastName: record.lastName,
|
||||
isActive: record.isActive,
|
||||
roleIds: record.userRoles.map((userRole) => userRole.role.id),
|
||||
roleNames: record.userRoles.map((userRole) => userRole.role.name),
|
||||
permissionKeys: [...permissionKeys].sort(),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuthSession(
|
||||
record: {
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
lastSeenAt: Date;
|
||||
revokedAt: Date | null;
|
||||
revokedReason: string | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: Date;
|
||||
user: {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
revokedBy: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} | null;
|
||||
},
|
||||
reviewContext: {
|
||||
reviewState: "NORMAL" | "REVIEW";
|
||||
reviewReasons: string[];
|
||||
},
|
||||
currentSessionId?: string
|
||||
): AdminAuthSessionDto {
|
||||
const now = Date.now();
|
||||
const status = record.revokedAt ? "REVOKED" : record.expiresAt.getTime() <= now ? "EXPIRED" : "ACTIVE";
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
userId: record.userId,
|
||||
userEmail: record.user.email,
|
||||
userName: `${record.user.firstName} ${record.user.lastName}`.trim(),
|
||||
status,
|
||||
reviewState: reviewContext.reviewState,
|
||||
reviewReasons: reviewContext.reviewReasons,
|
||||
isCurrent: record.id === currentSessionId,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
lastSeenAt: record.lastSeenAt.toISOString(),
|
||||
expiresAt: record.expiresAt.toISOString(),
|
||||
revokedAt: record.revokedAt?.toISOString() ?? null,
|
||||
revokedReason: record.revokedReason,
|
||||
revokedByName: record.revokedBy ? `${record.revokedBy.firstName} ${record.revokedBy.lastName}`.trim() : null,
|
||||
ipAddress: record.ipAddress,
|
||||
userAgent: record.userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
async function validatePermissionKeys(permissionKeys: string[]) {
|
||||
const uniquePermissionKeys = [...new Set(permissionKeys)];
|
||||
const permissions = await prisma.permission.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: uniquePermissionKeys,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (permissions.length !== uniquePermissionKeys.length) {
|
||||
return { ok: false as const, reason: "One or more selected permissions are invalid." };
|
||||
}
|
||||
|
||||
return { ok: true as const, permissions };
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds: string[]) {
|
||||
const uniqueRoleIds = [...new Set(roleIds)];
|
||||
const roles = await prisma.role.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: uniqueRoleIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (roles.length !== uniqueRoleIds.length) {
|
||||
return { ok: false as const, reason: "One or more selected roles are invalid." };
|
||||
}
|
||||
|
||||
return { ok: true as const, roles };
|
||||
}
|
||||
|
||||
export async function listAdminPermissions(): Promise<AdminPermissionOptionDto[]> {
|
||||
const permissions = await prisma.permission.findMany({
|
||||
orderBy: [{ key: "asc" }],
|
||||
});
|
||||
|
||||
return permissions.map((permission) => ({
|
||||
key: permission.key,
|
||||
description: permission.description,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listAdminRoles(): Promise<AdminRoleDto[]> {
|
||||
const roles = await prisma.role.findMany({
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userRoles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
});
|
||||
|
||||
return roles.map(mapRole);
|
||||
}
|
||||
|
||||
export async function createAdminRole(payload: AdminRoleInput, actorId?: string | null) {
|
||||
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
|
||||
if (!validatedPermissions.ok) {
|
||||
return { ok: false as const, reason: validatedPermissions.reason };
|
||||
}
|
||||
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
description: payload.description,
|
||||
rolePermissions: {
|
||||
create: validatedPermissions.permissions.map((permission) => ({
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userRoles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "role",
|
||||
entityId: role.id,
|
||||
action: "created",
|
||||
summary: `Created role ${role.name}.`,
|
||||
metadata: {
|
||||
name: role.name,
|
||||
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, role: mapRole(role) };
|
||||
}
|
||||
|
||||
export async function updateAdminRole(roleId: string, payload: AdminRoleInput, actorId?: string | null) {
|
||||
const existingRole = await prisma.role.findUnique({
|
||||
where: { id: roleId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
if (!existingRole) {
|
||||
return { ok: false as const, reason: "Role was not found." };
|
||||
}
|
||||
|
||||
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
|
||||
if (!validatedPermissions.ok) {
|
||||
return { ok: false as const, reason: validatedPermissions.reason };
|
||||
}
|
||||
|
||||
const role = await prisma.role.update({
|
||||
where: { id: roleId },
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
description: payload.description,
|
||||
rolePermissions: {
|
||||
deleteMany: {},
|
||||
create: validatedPermissions.permissions.map((permission) => ({
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userRoles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "role",
|
||||
entityId: role.id,
|
||||
action: "updated",
|
||||
summary: `Updated role ${role.name}.`,
|
||||
metadata: {
|
||||
previousName: existingRole.name,
|
||||
name: role.name,
|
||||
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, role: mapRole(role) };
|
||||
}
|
||||
|
||||
export async function listAdminUsers(): Promise<AdminUserDto[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
|
||||
});
|
||||
|
||||
return users.map(mapUser);
|
||||
}
|
||||
|
||||
export async function listAdminAuthSessions(currentSessionId?: string | null): Promise<AdminAuthSessionDto[]> {
|
||||
const sessions = await prisma.authSession.findMany({
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
revokedBy: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ revokedAt: "asc" }, { lastSeenAt: "desc" }, { createdAt: "desc" }],
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const activeSessionsByUser = new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
lastSeenAt: Date;
|
||||
}>
|
||||
>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const isActive = !session.revokedAt && session.expiresAt.getTime() > now;
|
||||
if (!isActive) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = activeSessionsByUser.get(session.userId) ?? [];
|
||||
existing.push({
|
||||
id: session.id,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
lastSeenAt: session.lastSeenAt,
|
||||
});
|
||||
activeSessionsByUser.set(session.userId, existing);
|
||||
}
|
||||
|
||||
return sessions.map((session) => {
|
||||
const reviewReasons: string[] = [];
|
||||
const activeUserSessions = activeSessionsByUser.get(session.userId) ?? [];
|
||||
const isActive = !session.revokedAt && session.expiresAt.getTime() > now;
|
||||
const staleThresholdMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (isActive && activeUserSessions.length > 1) {
|
||||
reviewReasons.push("Multiple active sessions");
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
const distinctIps = new Set(activeUserSessions.map((entry) => entry.ipAddress).filter(Boolean));
|
||||
if (distinctIps.size > 1) {
|
||||
reviewReasons.push("Multiple active IP addresses");
|
||||
}
|
||||
|
||||
if (now - session.lastSeenAt.getTime() > staleThresholdMs) {
|
||||
reviewReasons.push("Stale active session");
|
||||
}
|
||||
}
|
||||
|
||||
return mapAuthSession(
|
||||
session,
|
||||
{
|
||||
reviewState: reviewReasons.length > 0 ? "REVIEW" : "NORMAL",
|
||||
reviewReasons,
|
||||
},
|
||||
currentSessionId ?? undefined
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeAdminAuthSession(sessionId: string, actorId?: string | null) {
|
||||
const existingSession = await prisma.authSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSession) {
|
||||
return { ok: false as const, reason: "Session was not found." };
|
||||
}
|
||||
|
||||
if (existingSession.revokedAt) {
|
||||
return { ok: false as const, reason: "Session is already revoked." };
|
||||
}
|
||||
|
||||
await prisma.authSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
revokedById: actorId ?? null,
|
||||
revokedReason: "Revoked by administrator.",
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "auth-session",
|
||||
entityId: existingSession.id,
|
||||
action: "revoked",
|
||||
summary: `Revoked session for ${existingSession.user.email}.`,
|
||||
metadata: {
|
||||
userId: existingSession.userId,
|
||||
userEmail: existingSession.user.email,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
|
||||
if (!payload.password || payload.password.trim().length < 8) {
|
||||
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
|
||||
}
|
||||
|
||||
const validatedRoles = await validateRoleIds(payload.roleIds);
|
||||
if (!validatedRoles.ok) {
|
||||
return { ok: false as const, reason: validatedRoles.reason };
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: payload.email.trim().toLowerCase(),
|
||||
firstName: payload.firstName.trim(),
|
||||
lastName: payload.lastName.trim(),
|
||||
isActive: payload.isActive,
|
||||
passwordHash: await hashPassword(payload.password.trim()),
|
||||
userRoles: {
|
||||
create: validatedRoles.roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
assignedBy: actorId ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "created",
|
||||
summary: `Created user account for ${user.email}.`,
|
||||
metadata: {
|
||||
email: user.email,
|
||||
isActive: user.isActive,
|
||||
roleNames: user.userRoles.map((userRole) => userRole.role.name),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, user: mapUser(user) };
|
||||
}
|
||||
|
||||
export async function updateAdminUser(userId: string, payload: AdminUserInput, actorId?: string | null) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return { ok: false as const, reason: "User was not found." };
|
||||
}
|
||||
|
||||
const validatedRoles = await validateRoleIds(payload.roleIds);
|
||||
if (!validatedRoles.ok) {
|
||||
return { ok: false as const, reason: validatedRoles.reason };
|
||||
}
|
||||
|
||||
const data = {
|
||||
email: payload.email.trim().toLowerCase(),
|
||||
firstName: payload.firstName.trim(),
|
||||
lastName: payload.lastName.trim(),
|
||||
isActive: payload.isActive,
|
||||
...(payload.password?.trim()
|
||||
? {
|
||||
passwordHash: await hashPassword(payload.password.trim()),
|
||||
}
|
||||
: {}),
|
||||
userRoles: {
|
||||
deleteMany: {},
|
||||
create: validatedRoles.roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
assignedBy: actorId ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "updated",
|
||||
summary: `Updated user account for ${user.email}.`,
|
||||
metadata: {
|
||||
previousEmail: existingUser.email,
|
||||
email: user.email,
|
||||
isActive: user.isActive,
|
||||
roleNames: user.userRoles.map((userRole) => userRole.role.name),
|
||||
passwordReset: Boolean(payload.password?.trim()),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, user: mapUser(user) };
|
||||
}
|
||||
|
||||
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
||||
const startupReport = getLatestStartupReport();
|
||||
const recentSupportLogs = listSupportLogs({ limit: 50 });
|
||||
const now = new Date();
|
||||
const reviewSessions = await listAdminAuthSessions();
|
||||
const [
|
||||
companyProfile,
|
||||
userCount,
|
||||
activeUserCount,
|
||||
activeSessionCount,
|
||||
roleCount,
|
||||
permissionCount,
|
||||
customerCount,
|
||||
vendorCount,
|
||||
inventoryItemCount,
|
||||
warehouseCount,
|
||||
workOrderCount,
|
||||
projectCount,
|
||||
purchaseOrderCount,
|
||||
salesQuoteCount,
|
||||
salesOrderCount,
|
||||
shipmentCount,
|
||||
attachmentCount,
|
||||
auditEventCount,
|
||||
recentAuditEvents,
|
||||
] = await Promise.all([
|
||||
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { isActive: true } }),
|
||||
prisma.authSession.count({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
expiresAt: {
|
||||
gt: now,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.role.count(),
|
||||
prisma.permission.count(),
|
||||
prisma.customer.count(),
|
||||
prisma.vendor.count(),
|
||||
prisma.inventoryItem.count(),
|
||||
prisma.warehouse.count(),
|
||||
prisma.workOrder.count(),
|
||||
prisma.project.count(),
|
||||
prisma.purchaseOrder.count(),
|
||||
prisma.salesQuote.count(),
|
||||
prisma.salesOrder.count(),
|
||||
prisma.shipment.count(),
|
||||
prisma.fileAttachment.count(),
|
||||
prisma.auditEvent.count(),
|
||||
prisma.auditEvent.findMany({
|
||||
include: {
|
||||
actor: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 25,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
serverTime: new Date().toISOString(),
|
||||
nodeVersion: process.version,
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
dataDir: paths.dataDir,
|
||||
uploadsDir: paths.uploadsDir,
|
||||
clientOrigin: env.CLIENT_ORIGIN,
|
||||
companyProfilePresent: Boolean(companyProfile),
|
||||
userCount,
|
||||
activeUserCount,
|
||||
activeSessionCount,
|
||||
reviewSessionCount: reviewSessions.filter((session) => session.reviewState === "REVIEW").length,
|
||||
roleCount,
|
||||
permissionCount,
|
||||
customerCount,
|
||||
vendorCount,
|
||||
inventoryItemCount,
|
||||
warehouseCount,
|
||||
workOrderCount,
|
||||
projectCount,
|
||||
purchaseOrderCount,
|
||||
salesDocumentCount: salesQuoteCount + salesOrderCount,
|
||||
shipmentCount,
|
||||
attachmentCount,
|
||||
auditEventCount,
|
||||
supportLogCount: getSupportLogCount(),
|
||||
startup: startupReport,
|
||||
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
|
||||
recentSupportLogs: recentSupportLogs.entries.map(mapSupportLogEntry),
|
||||
};
|
||||
}
|
||||
|
||||
export function getBackupGuidance(): BackupGuidanceDto {
|
||||
return {
|
||||
dataPath: paths.dataDir,
|
||||
databasePath: `${paths.prismaDir}/app.db`,
|
||||
uploadsPath: paths.uploadsDir,
|
||||
recommendedBackupTarget: "/mnt/user/backups/mrp-codex",
|
||||
backupSteps: [
|
||||
{
|
||||
id: "stop-app",
|
||||
label: "Stop writes before copying data",
|
||||
detail: "Stop the container or application process before copying the data directory so SQLite and attachments stay consistent.",
|
||||
},
|
||||
{
|
||||
id: "copy-data",
|
||||
label: "Back up the full data directory",
|
||||
detail: `Copy the full data directory at ${paths.dataDir}, not just the SQLite file, so uploads and attachments are preserved with the database.`,
|
||||
},
|
||||
{
|
||||
id: "retain-metadata",
|
||||
label: "Keep timestamps and structure",
|
||||
detail: "Preserve directory structure, filenames, and timestamps during backup so support recovery remains straightforward.",
|
||||
},
|
||||
{
|
||||
id: "record-build",
|
||||
label: "Record image/version context",
|
||||
detail: "Capture the deployed image tag or commit alongside the backup so schema and runtime expectations are clear during restore.",
|
||||
},
|
||||
],
|
||||
restoreSteps: [
|
||||
{
|
||||
id: "stop-target",
|
||||
label: "Stop the target app before restore",
|
||||
detail: "Do not restore into a running instance. Stop the target container or process before replacing the data directory.",
|
||||
},
|
||||
{
|
||||
id: "replace-data",
|
||||
label: "Restore the full data directory",
|
||||
detail: `Replace the target data directory with the backed-up copy so ${paths.prismaDir}/app.db and uploads come back together.`,
|
||||
},
|
||||
{
|
||||
id: "start-and-migrate",
|
||||
label: "Start the app and let migrations run",
|
||||
detail: "Restart the application after restore and allow the normal startup migration flow to complete before validation.",
|
||||
},
|
||||
{
|
||||
id: "validate-core",
|
||||
label: "Validate login, files, and PDFs",
|
||||
detail: "Confirm admin login, attachment access, and PDF generation after restore to verify the operational surface is healthy.",
|
||||
},
|
||||
],
|
||||
verificationChecklist: [
|
||||
{
|
||||
id: "backup-size-check",
|
||||
label: "Confirm backup contains data and uploads",
|
||||
detail: "Verify the backup archive or copied directory includes the SQLite database and uploads tree rather than only one of them.",
|
||||
evidence: "Directory listing or archive manifest showing prisma/app.db and uploads/ content.",
|
||||
},
|
||||
{
|
||||
id: "timestamp-check",
|
||||
label: "Check backup freshness",
|
||||
detail: "Confirm the backup timestamp matches the expected backup window and is newer than the last major data-entry period you need to protect.",
|
||||
evidence: "Backup timestamp recorded in your scheduler, NAS share, or copied folder metadata.",
|
||||
},
|
||||
{
|
||||
id: "snapshot-export",
|
||||
label: "Capture a support snapshot with the backup",
|
||||
detail: "Export the support snapshot from diagnostics when taking a formal backup so the runtime state and active-user footprint are recorded alongside it.",
|
||||
evidence: "JSON support snapshot stored with the backup set or support ticket.",
|
||||
},
|
||||
{
|
||||
id: "app-stop-check",
|
||||
label: "Verify writes were stopped before copy",
|
||||
detail: "Use a controlled maintenance stop or container stop before backup to reduce the chance of a partial SQLite copy.",
|
||||
evidence: "Maintenance log entry, Docker stop event, or operator note recorded with the backup.",
|
||||
},
|
||||
],
|
||||
restoreDrillSteps: [
|
||||
{
|
||||
id: "prepare-drill-target",
|
||||
label: "Prepare isolated restore target",
|
||||
detail: "Restore into an isolated container or duplicate environment instead of the live production instance.",
|
||||
expectedOutcome: "A clean target environment is ready to receive the backed-up data directory without impacting production.",
|
||||
},
|
||||
{
|
||||
id: "load-backed-up-data",
|
||||
label: "Load the full backup set",
|
||||
detail: `Restore the full backed-up data directory so ${paths.prismaDir}/app.db and uploads are returned together.`,
|
||||
expectedOutcome: "The restore target contains both database and file assets with the original directory structure intact.",
|
||||
},
|
||||
{
|
||||
id: "boot-restored-app",
|
||||
label: "Start the restored application",
|
||||
detail: "Launch the restored app and allow startup validation plus migrations to complete normally.",
|
||||
expectedOutcome: "The application starts without startup-validation failures and the diagnostics page loads.",
|
||||
},
|
||||
{
|
||||
id: "run-functional-checks",
|
||||
label: "Run post-restore functional checks",
|
||||
detail: "Verify login, one attachment download, one PDF render, and one representative transactional detail page such as inventory, purchasing, or shipping.",
|
||||
expectedOutcome: "Core operational flows work in the restored environment and file/PDF dependencies remain valid.",
|
||||
},
|
||||
{
|
||||
id: "record-drill-results",
|
||||
label: "Record restore-drill results",
|
||||
detail: "Capture the drill date, backup source used, startup status, and any gaps discovered so future recovery work improves over time.",
|
||||
expectedOutcome: "A dated restore-drill record exists for support and disaster-recovery review.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSupportSnapshot(filters?: SupportLogFiltersDto): Promise<SupportSnapshotDto> {
|
||||
const diagnostics = await getAdminDiagnostics();
|
||||
const backupGuidance = getBackupGuidance();
|
||||
const supportLogs = listSupportLogs({ limit: 200, ...filters });
|
||||
const [users, roles] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { email: true },
|
||||
orderBy: [{ email: "asc" }],
|
||||
}),
|
||||
prisma.role.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
diagnostics,
|
||||
userCount: diagnostics.userCount,
|
||||
roleCount: roles,
|
||||
activeUserEmails: users.map((user) => user.email),
|
||||
backupGuidance,
|
||||
supportLogs: mapSupportLogList(supportLogs),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSupportLogs(filters?: SupportLogFiltersDto) {
|
||||
return mapSupportLogList(listSupportLogs(filters));
|
||||
}
|
||||
|
||||
export function getSupportLogRetentionPolicy() {
|
||||
return {
|
||||
retentionDays: getSupportLogRetentionDays(),
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requireAuth } from "../../lib/rbac.js";
|
||||
import { login } from "./service.js";
|
||||
import { login, logout } from "./service.js";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -18,7 +18,10 @@ authRouter.post("/login", async (request, response) => {
|
||||
return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password.");
|
||||
}
|
||||
|
||||
const result = await login(parsed.data);
|
||||
const result = await login(parsed.data, {
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.header("user-agent"),
|
||||
});
|
||||
if (!result) {
|
||||
return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect.");
|
||||
}
|
||||
@@ -28,3 +31,11 @@ authRouter.post("/login", async (request, response) => {
|
||||
|
||||
authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser));
|
||||
|
||||
authRouter.post("/logout", requireAuth, async (request, response) => {
|
||||
if (!request.authSessionId || !request.authUser) {
|
||||
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
|
||||
}
|
||||
|
||||
await logout(request.authSessionId, request.authUser.id);
|
||||
return ok(response, { success: true as const });
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { LoginRequest, LoginResponse } from "@mrp/shared";
|
||||
|
||||
import { signToken } from "../../lib/auth.js";
|
||||
import { createAuthSession, revokeAuthSession } from "../../lib/auth-sessions.js";
|
||||
import { getCurrentUserById } from "../../lib/current-user.js";
|
||||
import { verifyPassword } from "../../lib/password.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
export async function login(payload: LoginRequest): Promise<LoginResponse | null> {
|
||||
export async function login(
|
||||
payload: LoginRequest,
|
||||
context?: {
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
): Promise<LoginResponse | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: payload.email.toLowerCase() },
|
||||
});
|
||||
@@ -23,9 +30,21 @@ export async function login(payload: LoginRequest): Promise<LoginResponse | null
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await createAuthSession({
|
||||
userId: user.id,
|
||||
ipAddress: context?.ipAddress ?? null,
|
||||
userAgent: context?.userAgent ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
token: signToken(authUser),
|
||||
token: signToken(authUser, session.id),
|
||||
user: authUser,
|
||||
};
|
||||
}
|
||||
|
||||
export async function logout(sessionId: string, actorId?: string | null) {
|
||||
await revokeAuthSession(sessionId, {
|
||||
revokedById: actorId ?? null,
|
||||
reason: actorId ? "User signed out." : "Session signed out.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
|
||||
}
|
||||
|
||||
const customer = await createCustomer(parsed.data);
|
||||
const customer = await createCustomer(parsed.data, request.authUser?.id);
|
||||
if (!customer) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
||||
}
|
||||
@@ -143,7 +143,7 @@ crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite
|
||||
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
||||
}
|
||||
|
||||
const customer = await updateCustomer(customerId, parsed.data);
|
||||
const customer = await updateCustomer(customerId, parsed.data, request.authUser?.id);
|
||||
if (!customer) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
||||
}
|
||||
@@ -181,7 +181,7 @@ crmRouter.post("/customers/:customerId/contacts", requirePermissions([permission
|
||||
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
||||
}
|
||||
|
||||
const contact = await createCustomerContact(customerId, parsed.data);
|
||||
const contact = await createCustomerContact(customerId, parsed.data, request.authUser?.id);
|
||||
if (!contact) {
|
||||
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
||||
}
|
||||
@@ -227,7 +227,7 @@ crmRouter.post("/vendors", requirePermissions([permissions.crmWrite]), async (re
|
||||
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createVendor(parsed.data), 201);
|
||||
return ok(response, await createVendor(parsed.data, request.authUser?.id), 201);
|
||||
});
|
||||
|
||||
crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
||||
@@ -241,7 +241,7 @@ crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]),
|
||||
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
|
||||
}
|
||||
|
||||
const vendor = await updateVendor(vendorId, parsed.data);
|
||||
const vendor = await updateVendor(vendorId, parsed.data, request.authUser?.id);
|
||||
if (!vendor) {
|
||||
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
|
||||
}
|
||||
@@ -279,7 +279,7 @@ crmRouter.post("/vendors/:vendorId/contacts", requirePermissions([permissions.cr
|
||||
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
||||
}
|
||||
|
||||
const contact = await createVendorContact(vendorId, parsed.data);
|
||||
const contact = await createVendorContact(vendorId, parsed.data, request.authUser?.id);
|
||||
if (!contact) {
|
||||
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from "@mrp/shared/dist/crm/types.js";
|
||||
import type { Customer, Vendor } from "@prisma/client";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
|
||||
@@ -397,7 +398,7 @@ export async function getCustomerById(customerId: string) {
|
||||
return mapCustomerDetail(customer, attachmentCount);
|
||||
}
|
||||
|
||||
export async function createCustomer(payload: CrmRecordInput) {
|
||||
export async function createCustomer(payload: CrmRecordInput, actorId?: string | null) {
|
||||
if (payload.parentCustomerId) {
|
||||
const parentCustomer = await prisma.customer.findUnique({
|
||||
where: { id: payload.parentCustomerId },
|
||||
@@ -436,6 +437,20 @@ export async function createCustomer(payload: CrmRecordInput) {
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "crm-customer",
|
||||
entityId: customer.id,
|
||||
action: "created",
|
||||
summary: `Created customer ${customer.name}.`,
|
||||
metadata: {
|
||||
name: customer.name,
|
||||
status: customer.status,
|
||||
lifecycleStage: customer.lifecycleStage,
|
||||
isReseller: customer.isReseller,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mapDetail(customer),
|
||||
isReseller: customer.isReseller,
|
||||
@@ -463,7 +478,7 @@ export async function createCustomer(payload: CrmRecordInput) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCustomer(customerId: string, payload: CrmRecordInput) {
|
||||
export async function updateCustomer(customerId: string, payload: CrmRecordInput, actorId?: string | null) {
|
||||
const existingCustomer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
});
|
||||
@@ -515,6 +530,20 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "crm-customer",
|
||||
entityId: customer.id,
|
||||
action: "updated",
|
||||
summary: `Updated customer ${customer.name}.`,
|
||||
metadata: {
|
||||
name: customer.name,
|
||||
status: customer.status,
|
||||
lifecycleStage: customer.lifecycleStage,
|
||||
isReseller: customer.isReseller,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mapDetail(customer),
|
||||
isReseller: customer.isReseller,
|
||||
@@ -630,7 +659,7 @@ export async function getVendorById(vendorId: string) {
|
||||
return mapVendorDetail(vendor, attachmentCount);
|
||||
}
|
||||
|
||||
export async function createVendor(payload: CrmRecordInput) {
|
||||
export async function createVendor(payload: CrmRecordInput, actorId?: string | null) {
|
||||
const vendor = await prisma.vendor.create({
|
||||
data: {
|
||||
name: payload.name,
|
||||
@@ -656,6 +685,19 @@ export async function createVendor(payload: CrmRecordInput) {
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "crm-vendor",
|
||||
entityId: vendor.id,
|
||||
action: "created",
|
||||
summary: `Created vendor ${vendor.name}.`,
|
||||
metadata: {
|
||||
name: vendor.name,
|
||||
status: vendor.status,
|
||||
lifecycleStage: vendor.lifecycleStage,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mapDetail(vendor),
|
||||
paymentTerms: vendor.paymentTerms,
|
||||
@@ -677,7 +719,7 @@ export async function createVendor(payload: CrmRecordInput) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
|
||||
export async function updateVendor(vendorId: string, payload: CrmRecordInput, actorId?: string | null) {
|
||||
const existingVendor = await prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
});
|
||||
@@ -712,6 +754,19 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "crm-vendor",
|
||||
entityId: vendor.id,
|
||||
action: "updated",
|
||||
summary: `Updated vendor ${vendor.name}.`,
|
||||
metadata: {
|
||||
name: vendor.name,
|
||||
status: vendor.status,
|
||||
lifecycleStage: vendor.lifecycleStage,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mapDetail(vendor),
|
||||
paymentTerms: vendor.paymentTerms,
|
||||
@@ -756,6 +811,19 @@ export async function createCustomerContactEntry(customerId: string, payload: Cr
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "crm-customer",
|
||||
entityId: customerId,
|
||||
action: "contact-entry.created",
|
||||
summary: `Added ${payload.type.toLowerCase()} contact history for customer ${existingCustomer.name}.`,
|
||||
metadata: {
|
||||
type: payload.type,
|
||||
summary: payload.summary,
|
||||
contactAt: payload.contactAt,
|
||||
},
|
||||
});
|
||||
|
||||
return mapContactEntry(entry);
|
||||
}
|
||||
|
||||
@@ -782,10 +850,23 @@ export async function createVendorContactEntry(vendorId: string, payload: CrmCon
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "crm-vendor",
|
||||
entityId: vendorId,
|
||||
action: "contact-entry.created",
|
||||
summary: `Added ${payload.type.toLowerCase()} contact history for vendor ${existingVendor.name}.`,
|
||||
metadata: {
|
||||
type: payload.type,
|
||||
summary: payload.summary,
|
||||
contactAt: payload.contactAt,
|
||||
},
|
||||
});
|
||||
|
||||
return mapContactEntry(entry);
|
||||
}
|
||||
|
||||
export async function createCustomerContact(customerId: string, payload: CrmContactInput) {
|
||||
export async function createCustomerContact(customerId: string, payload: CrmContactInput, actorId?: string | null) {
|
||||
const existingCustomer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
});
|
||||
@@ -812,10 +893,24 @@ export async function createCustomerContact(customerId: string, payload: CrmCont
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "crm-customer",
|
||||
entityId: customerId,
|
||||
action: "contact.created",
|
||||
summary: `Added contact ${contact.fullName} to customer ${existingCustomer.name}.`,
|
||||
metadata: {
|
||||
fullName: contact.fullName,
|
||||
role: contact.role,
|
||||
email: contact.email,
|
||||
isPrimary: contact.isPrimary,
|
||||
},
|
||||
});
|
||||
|
||||
return mapCrmContact(contact);
|
||||
}
|
||||
|
||||
export async function createVendorContact(vendorId: string, payload: CrmContactInput) {
|
||||
export async function createVendorContact(vendorId: string, payload: CrmContactInput, actorId?: string | null) {
|
||||
const existingVendor = await prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
});
|
||||
@@ -842,5 +937,19 @@ export async function createVendorContact(vendorId: string, payload: CrmContactI
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "crm-vendor",
|
||||
entityId: vendorId,
|
||||
action: "contact.created",
|
||||
summary: `Added contact ${contact.fullName} to vendor ${existingVendor.name}.`,
|
||||
metadata: {
|
||||
fullName: contact.fullName,
|
||||
role: contact.role,
|
||||
email: contact.email,
|
||||
isPrimary: contact.isPrimary,
|
||||
},
|
||||
});
|
||||
|
||||
return mapCrmContact(contact);
|
||||
}
|
||||
|
||||
@@ -152,29 +152,40 @@ function buildShippingLabelPdf(options: {
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
@page { size: 4in 6in; margin: 8mm; }
|
||||
body { font-family: ${company.theme.fontFamily}, 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; min-height: calc(6in - 16mm); box-sizing: border-box; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; }
|
||||
@page { size: 4in 6in; margin: 0; }
|
||||
*, *::before, *::after { 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; }
|
||||
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; }
|
||||
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; }
|
||||
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
|
||||
.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; }
|
||||
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 0.09in; }
|
||||
.brand-row { align-items: flex-start; }
|
||||
.brand-company { flex: 1; min-width: 0; padding-right: 0.06in; }
|
||||
.brand h1 { margin: 0; font-size: 16px; line-height: 1.05; color: ${company.theme.primaryColor}; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">
|
||||
<div class="page">
|
||||
<div class="label">
|
||||
<div class="brand">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="row brand-row">
|
||||
<div class="brand-company">
|
||||
<div class="muted">From</div>
|
||||
<h1>${escapeHtml(company.companyName)}</h1>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="shipment-number">
|
||||
<div class="muted">Shipment</div>
|
||||
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
|
||||
</div>
|
||||
@@ -217,7 +228,7 @@ function buildShippingLabelPdf(options: {
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
`, { width: "4in", height: "6in", margin: { top: "0", right: "0", bottom: "0", left: "0" } });
|
||||
}
|
||||
|
||||
function buildBillOfLadingPdf(options: {
|
||||
|
||||
104
server/src/modules/finance/router.ts
Normal file
104
server/src/modules/finance/router.ts
Normal 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);
|
||||
});
|
||||
619
server/src/modules/finance/service.ts
Normal file
619
server/src/modules/finance/service.ts
Normal 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
@@ -8,10 +8,16 @@ import { requirePermissions } from "../../lib/rbac.js";
|
||||
import {
|
||||
createInventoryItem,
|
||||
createInventoryReservation,
|
||||
createInventorySkuFamily,
|
||||
createInventorySkuNode,
|
||||
createInventoryTransfer,
|
||||
createInventoryTransaction,
|
||||
createWarehouse,
|
||||
getInventoryItemById,
|
||||
listInventorySkuCatalog,
|
||||
listInventorySkuFamilies,
|
||||
listInventorySkuNodeOptions,
|
||||
previewInventorySku,
|
||||
getWarehouseById,
|
||||
listInventoryItemOptions,
|
||||
listInventoryItems,
|
||||
@@ -40,6 +46,12 @@ const operationSchema = z.object({
|
||||
|
||||
const inventoryItemSchema = z.object({
|
||||
sku: z.string().trim().min(1).max(64),
|
||||
skuBuilder: z
|
||||
.object({
|
||||
familyId: z.string().trim().min(1),
|
||||
nodeId: z.string().trim().min(1).nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string(),
|
||||
type: z.enum(inventoryItemTypes),
|
||||
@@ -47,6 +59,7 @@ const inventoryItemSchema = z.object({
|
||||
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
|
||||
isSellable: z.boolean(),
|
||||
isPurchasable: z.boolean(),
|
||||
preferredVendorId: z.string().trim().min(1).nullable(),
|
||||
defaultCost: z.number().nonnegative().nullable(),
|
||||
defaultPrice: z.number().nonnegative().nullable(),
|
||||
notes: z.string(),
|
||||
@@ -98,6 +111,34 @@ const warehouseSchema = z.object({
|
||||
locations: z.array(warehouseLocationSchema),
|
||||
});
|
||||
|
||||
const skuFamilySchema = z.object({
|
||||
code: z.string().trim().min(2).max(12),
|
||||
sequenceCode: z.string().trim().min(2).max(2),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string(),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
const skuNodeSchema = z.object({
|
||||
familyId: z.string().trim().min(1),
|
||||
parentNodeId: z.string().trim().min(1).nullable(),
|
||||
code: z.string().trim().min(1).max(32),
|
||||
label: z.string().trim().min(1).max(160),
|
||||
description: z.string(),
|
||||
sortOrder: z.number().int().nonnegative(),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
const skuPreviewQuerySchema = z.object({
|
||||
familyId: z.string().trim().min(1),
|
||||
nodeId: z.string().trim().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
const skuNodeOptionsQuerySchema = z.object({
|
||||
familyId: z.string().trim().min(1),
|
||||
parentNodeId: z.string().trim().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
@@ -124,6 +165,46 @@ inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryR
|
||||
return ok(response, await listInventoryItemOptions());
|
||||
});
|
||||
|
||||
inventoryRouter.get("/sku/families", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
|
||||
return ok(response, await listInventorySkuFamilies());
|
||||
});
|
||||
|
||||
inventoryRouter.get("/sku/catalog", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
|
||||
return ok(response, await listInventorySkuCatalog());
|
||||
});
|
||||
|
||||
inventoryRouter.get("/sku/nodes", requirePermissions([permissions.inventoryRead]), async (request, response) => {
|
||||
const parsed = skuNodeOptionsQuerySchema.safeParse({
|
||||
familyId: request.query.familyId,
|
||||
parentNodeId: request.query.parentNodeId ?? null,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "SKU node filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await listInventorySkuNodeOptions(parsed.data.familyId, parsed.data.parentNodeId ?? null));
|
||||
});
|
||||
|
||||
inventoryRouter.get("/sku/preview", requirePermissions([permissions.inventoryRead]), async (request, response) => {
|
||||
const parsed = skuPreviewQuerySchema.safeParse({
|
||||
familyId: request.query.familyId,
|
||||
nodeId: request.query.nodeId ?? null,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
|
||||
}
|
||||
|
||||
const preview = await previewInventorySku({
|
||||
familyId: parsed.data.familyId,
|
||||
nodeId: parsed.data.nodeId ?? null,
|
||||
});
|
||||
if (!preview) {
|
||||
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, preview);
|
||||
});
|
||||
|
||||
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
|
||||
return ok(response, await listWarehouseLocationOptions());
|
||||
});
|
||||
@@ -148,7 +229,7 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
|
||||
}
|
||||
|
||||
const item = await createInventoryItem(parsed.data);
|
||||
const item = await createInventoryItem(parsed.data, request.authUser?.id);
|
||||
if (!item) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item BOM references are invalid.");
|
||||
}
|
||||
@@ -156,6 +237,34 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
|
||||
return ok(response, item, 201);
|
||||
});
|
||||
|
||||
inventoryRouter.post("/sku/families", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||
const parsed = skuFamilySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
|
||||
}
|
||||
|
||||
const family = await createInventorySkuFamily(parsed.data);
|
||||
if (!family) {
|
||||
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, family, 201);
|
||||
});
|
||||
|
||||
inventoryRouter.post("/sku/nodes", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||
const parsed = skuNodeSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
|
||||
}
|
||||
|
||||
const node = await createInventorySkuNode(parsed.data);
|
||||
if (!node) {
|
||||
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, node, 201);
|
||||
});
|
||||
|
||||
inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||
const itemId = getRouteParam(request.params.itemId);
|
||||
if (!itemId) {
|
||||
@@ -167,7 +276,7 @@ inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryW
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
|
||||
}
|
||||
|
||||
const item = await updateInventoryItem(itemId, parsed.data);
|
||||
const item = await updateInventoryItem(itemId, parsed.data, request.authUser?.id);
|
||||
if (!item) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item or BOM references are invalid.");
|
||||
}
|
||||
@@ -224,7 +333,7 @@ inventoryRouter.post("/items/:itemId/reservations", requirePermissions([permissi
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory reservation payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createInventoryReservation(itemId, parsed.data);
|
||||
const result = await createInventoryReservation(itemId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -256,7 +365,7 @@ inventoryRouter.post("/warehouses", requirePermissions([permissions.inventoryWri
|
||||
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createWarehouse(parsed.data), 201);
|
||||
return ok(response, await createWarehouse(parsed.data, request.authUser?.id), 201);
|
||||
});
|
||||
|
||||
inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||
@@ -270,7 +379,7 @@ inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.
|
||||
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
|
||||
}
|
||||
|
||||
const warehouse = await updateWarehouse(warehouseId, parsed.data);
|
||||
const warehouse = await updateWarehouse(warehouseId, parsed.data, request.authUser?.id);
|
||||
if (!warehouse) {
|
||||
return fail(response, 404, "WAREHOUSE_NOT_FOUND", "Warehouse was not found.");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,16 @@ import {
|
||||
listManufacturingItemOptions,
|
||||
listManufacturingProjectOptions,
|
||||
listManufacturingStations,
|
||||
listManufacturingUserOptions,
|
||||
listWorkOrders,
|
||||
recordWorkOrderCompletion,
|
||||
recordWorkOrderOperationLabor,
|
||||
updateManufacturingStation,
|
||||
updateWorkOrderOperationAssignment,
|
||||
updateWorkOrderOperationExecution,
|
||||
updateWorkOrderOperationTimer,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderOperationSchedule,
|
||||
updateWorkOrderStatus,
|
||||
} from "./service.js";
|
||||
|
||||
@@ -24,12 +31,17 @@ const stationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string(),
|
||||
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(),
|
||||
});
|
||||
|
||||
const workOrderSchema = z.object({
|
||||
itemId: z.string().trim().min(1),
|
||||
projectId: z.string().trim().min(1).nullable(),
|
||||
salesOrderId: z.string().trim().min(1).nullable(),
|
||||
salesOrderLineId: z.string().trim().min(1).nullable(),
|
||||
status: z.enum(workOrderStatuses),
|
||||
quantity: z.number().int().positive(),
|
||||
warehouseId: z.string().trim().min(1),
|
||||
@@ -47,6 +59,7 @@ const workOrderFiltersSchema = z.object({
|
||||
|
||||
const statusUpdateSchema = z.object({
|
||||
status: z.enum(workOrderStatuses),
|
||||
reason: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const materialIssueSchema = z.object({
|
||||
@@ -62,6 +75,30 @@ const completionSchema = z.object({
|
||||
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) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
@@ -76,6 +113,10 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
|
||||
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) => {
|
||||
return ok(response, await listManufacturingStations());
|
||||
});
|
||||
@@ -86,7 +127,26 @@ manufacturingRouter.post("/stations", requirePermissions([permissions.manufactur
|
||||
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createManufacturingStation(parsed.data), 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) => {
|
||||
@@ -118,7 +178,7 @@ manufacturingRouter.post("/work-orders", requirePermissions([permissions.manufac
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createWorkOrder(parsed.data);
|
||||
const result = await createWorkOrder(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -137,7 +197,7 @@ manufacturingRouter.put("/work-orders/:workOrderId", requirePermissions([permiss
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateWorkOrder(workOrderId, parsed.data);
|
||||
const result = await updateWorkOrder(workOrderId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -156,7 +216,107 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status);
|
||||
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) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user