Compare commits
114 Commits
84bd962744
...
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 | |||
| 1fcb0c5480 | |||
| 16582d3cea | |||
| a9d31730f8 | |||
| e2254d020e | |||
| 0596970b99 | |||
| 6644ba2932 | |||
| 552d4e2844 | |||
| ce21ad4a4c | |||
| 18e4044124 | |||
| 5a1164f497 | |||
| 4c4db687e0 | |||
| d9b60859d9 | |||
| 9d8f6767fb | |||
| 21c2bbeaa8 | |||
| 3323435114 | |||
| f66001e514 | |||
| 4a2be400c5 | |||
| 006b14d93d | |||
| 86588c6134 | |||
| 5f93adab8b | |||
| 7b85d14ff6 | |||
| ff37ad6f06 | |||
| a54e5901f0 | |||
| c21f7c2cee | |||
| 9d233a0c3d | |||
| d44d97e47b | |||
| 8bf69c67e0 | |||
| ce6dec316e | |||
| 10b47da724 | |||
| 6589581908 | |||
| 2cf6bf858d | |||
| a6b24a6609 | |||
| ce2f94c17e | |||
| 240922b417 | |||
| 65269f172f | |||
| 472c36915c | |||
| d21e2e3c0b | |||
| df3f1412f6 | |||
| c0cc546e33 | |||
| f1fd2ed979 | |||
| b776ec3381 | |||
| 70f55c98b5 | |||
| a8d0533f4a | |||
| 9c8298c5e3 |
49
AGENTS.md
49
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
|
||||
@@ -14,6 +14,30 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
|
||||
- local JWT auth and RBAC
|
||||
- Company Settings and runtime branding
|
||||
- filesystem-backed attachments
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -21,13 +45,15 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
|
||||
|
||||
Read these before major work:
|
||||
|
||||
- [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md)
|
||||
- [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)
|
||||
- [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md)
|
||||
|
||||
If implementation changes invalidate those docs, update them in the same change set.
|
||||
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.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
@@ -94,16 +120,23 @@ If implementation changes invalidate those docs, update them in the same change
|
||||
- Light and dark mode must remain first-class, not bolted on later
|
||||
- New UI should respect the theme system and avoid hardcoded one-off colors where possible
|
||||
- Keep the interface intentional and operational, not generic admin-template filler
|
||||
- Non-filter operational lookups must use searchable pickers/autocomplete instead of long static dropdowns
|
||||
- Keep the denser UI baseline on active screens unless a specific workflow needs more space
|
||||
- Inventory items maintain both cost and price; sales entry should default from item price
|
||||
- Purchase-order item lookup must only expose inventory items flagged as purchasable
|
||||
- Customer-facing and logistics PDFs should continue to use the backend documents module and Puppeteer pipeline
|
||||
- The landing experience should remain `Dashboard`, not `Overview`, and should evolve as a modular metric-first operational surface
|
||||
- Projects are a first-class domain that anchors long-running program execution across CRM, sales, inventory, purchasing, shipping, and planning, and future work should continue extending that module rather than scattering project state elsewhere
|
||||
- Manufacturing is now a first-class domain for work orders and inventory-backed execution, and future work should keep expanding it as a separate subsystem for routings, labor, and shop-floor control
|
||||
- Planning should remain the scheduling/visibility layer over projects and manufacturing, not a replacement for either
|
||||
- New top-level modules added to shell navigation should ship with a matching SVG icon, not text-only nav entries
|
||||
|
||||
## Feature expectations
|
||||
|
||||
Near-term priorities are:
|
||||
|
||||
1. CRM detail and edit workflows
|
||||
2. Inventory and BOM data model
|
||||
3. Sales and quote foundation
|
||||
4. Shipping tied to sales orders
|
||||
5. Live manufacturing gantt scheduling
|
||||
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.
|
||||
|
||||
@@ -121,7 +154,7 @@ If you cannot run one of those checks, say so explicitly.
|
||||
## Git and workflow expectations
|
||||
|
||||
- Keep commits focused and source-only; do not commit generated local build artifacts
|
||||
- Update roadmap/docs when major work shifts priorities or architecture
|
||||
- Update roadmap/docs and `CHANGELOG.md` when major work shifts priorities, architecture, or shipped functionality
|
||||
- Do not remove or overwrite user changes without explicit instruction
|
||||
- If a task reveals a persistent operational issue, document it rather than leaving it tribal knowledge
|
||||
|
||||
|
||||
195
CHANGELOG.md
Normal file
195
CHANGELOG.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Changelog
|
||||
|
||||
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 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
|
||||
- Projects domain foundation with customer, owner, due date, priority, notes, and attachment support
|
||||
- Project linkage to sales quotes, sales orders, and shipments for cross-module delivery tracking
|
||||
- Project list/detail/create/edit workflows and app-shell navigation entry
|
||||
- Dashboard project widgets for active, at-risk, and overdue program visibility
|
||||
- Manufacturing foundation with work orders, optional project linkage, work-order attachments, and app-shell navigation entry
|
||||
- BOM-based manufacturing requirement visibility plus material issue and completion posting through inventory transactions
|
||||
- Dashboard manufacturing widgets for released, active, and overdue work visibility
|
||||
- Purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and procurement backup files
|
||||
- Vendor-detail purchasing visibility with recent purchase-order activity and PO launch shortcuts
|
||||
|
||||
### Changed
|
||||
|
||||
- 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 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
|
||||
- Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns
|
||||
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
|
||||
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
|
||||
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
- Dashboard foundation followed by live-data dashboard cards and operational summary widgets
|
||||
- Purchase-order domain foundation with searchable vendor and SKU workflows
|
||||
- Purchase receiving foundation tied to purchase orders, including warehouse/location receipt posting, receipt history, and per-line received and remaining quantity tracking
|
||||
- Branded quote, sales-order, and purchase-order PDFs through the shared backend Puppeteer document pipeline
|
||||
- Navigation icon support for top-level modules
|
||||
|
||||
### Changed
|
||||
|
||||
- Purchasing detail workflows now support operational receiving directly from the purchase-order record
|
||||
- Sales and purchasing detail pages now expose one-click PDF rendering actions
|
||||
- Dashboard direction and project instructions were tightened through new documentation guidance
|
||||
- Roadmap and project docs now treat shipping/logistics documents as the next active priority after receiving and commercial PDFs
|
||||
|
||||
### Fixed
|
||||
|
||||
- Purchase-order follow-up fixes after the initial purchasing rollout
|
||||
- PDF rendering and document-pipeline fixes after the first document commits
|
||||
- General refinement pass across active workflows after the initial March 15 feature wave
|
||||
|
||||
## 2026-03-14
|
||||
|
||||
### Added
|
||||
|
||||
- Initial MRP foundation scaffold
|
||||
- Docker build and runtime foundation, plus Unraid deployment guidance
|
||||
- Prisma setup, migration fixes, npm/workspace fixes, and supporting JS/runtime cleanup
|
||||
- Auth/RBAC foundation, dark mode support, and early UI cleanup
|
||||
- CRM foundation through customer/vendor records, hierarchy, contacts, lifecycle metadata, images, and final CRM polish
|
||||
- Inventory foundation through item master, BOMs, warehouses, stock locations, searchable SKU flows, and inventory detail refinements
|
||||
- Dense workspace and compact UI passes across the active product surface
|
||||
- Sales foundation with searchable document entry, conversion actions, default cost/price support, and totals/commercial logic
|
||||
- Shipping foundation with sales-order linkage and packing-slip PDFs
|
||||
- Company-profile and document PDF foundation with subsequent documentation updates
|
||||
|
||||
### Changed
|
||||
|
||||
- Inventory default-price support flowing into sales documents
|
||||
- Purchase-order line restrictions to purchasable inventory items only
|
||||
- Search and focus behavior received follow-up fixes during CRM/inventory/sales rollout
|
||||
- Documentation was expanded repeatedly as the foundation scope grew
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prisma reliability and workspace setup issues discovered during the first foundation build-out
|
||||
- SKU picker and autocomplete issues during inventory rollout
|
||||
- Focus and autosuggest regressions during dense operational form work
|
||||
|
||||
### Known follow-up areas
|
||||
|
||||
- Shipping labels, bills of lading, and logistics attachments
|
||||
- Vendor invoice/supporting-document attachments
|
||||
- Sales approvals and document revision history
|
||||
- Projects, manufacturing execution, and planning depth
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Development Instructions
|
||||
|
||||
## 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), [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
|
||||
|
||||
This repository implements the platform foundation milestone:
|
||||
@@ -8,6 +13,35 @@ This repository implements the platform foundation milestone:
|
||||
- local auth and RBAC
|
||||
- company settings and branding
|
||||
- file attachment storage
|
||||
- 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, 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
|
||||
|
||||
@@ -18,6 +52,16 @@ This repository implements the platform foundation milestone:
|
||||
3. Add Prisma models and migrations for all persisted schema changes.
|
||||
4. Keep uploaded files on disk under `/app/data/uploads`; never store blobs in SQLite.
|
||||
5. Reuse shared DTOs and permission keys from the `shared` package.
|
||||
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. 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
|
||||
|
||||
@@ -27,11 +71,14 @@ This repository implements the platform foundation milestone:
|
||||
- Prefer Node 22 locally when running Prisma migration commands to match the Docker runtime.
|
||||
- Branding defaults live in the frontend theme token layer and are overridden by the persisted company profile.
|
||||
- Back up the whole `/app/data` volume to capture both the database and attachments.
|
||||
- Treat searchable lookup as a standing UX requirement for inventory, BOM, sales, purchasing, manufacturing, customer, vendor, and other operational record-picking flows. Filter-only controls can still use dropdowns.
|
||||
- Extend the existing Puppeteer document pipeline when adding customer-facing or logistics PDFs instead of creating a parallel export mechanism.
|
||||
- Add future dashboard features as modular metric/action panels instead of one-off hero sections or static marketing-style content.
|
||||
- When implementing projects, model the relationships explicitly so project records can anchor execution across customer, order, material, schedule, and shipment workflows.
|
||||
- When implementing manufacturing, keep work orders, routings, labor, and shop-floor execution in their own domain rather than collapsing them into projects.
|
||||
|
||||
## Next roadmap candidates
|
||||
|
||||
- CRM entity detail pages and search
|
||||
- inventory and BOM management
|
||||
- sales orders, purchase orders, and document templates
|
||||
- shipping workflows and printable logistics documents
|
||||
- manufacturing gantt scheduling with live project data
|
||||
- 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.
|
||||
373
README.md
373
README.md
@@ -1,14 +1,174 @@
|
||||
# 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), [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, 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, 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 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
|
||||
- CRM foundation
|
||||
- inventory foundation
|
||||
- sales and purchasing foundation
|
||||
- shipping foundation
|
||||
- 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. 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
|
||||
- deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
|
||||
|
||||
Dashboard direction:
|
||||
|
||||
- the landing page is now `Dashboard`, not `Overview`
|
||||
- it should remain a metric-oriented operational surface rather than a generic welcome page
|
||||
- new modules should add reusable dashboard cards/panels instead of replacing the whole layout
|
||||
- future additions should emphasize relevant metrics, next actions, alerts, and workflow shortcuts
|
||||
- richer recent-activity widgets and exception queues are a planned QOL follow-up, not a separate landing-page redesign
|
||||
- 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 the live workbench schedule from project and manufacturing records
|
||||
- future project widgets should deepen milestones, shortages, and shipment readiness
|
||||
|
||||
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, 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 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, 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:
|
||||
|
||||
- Projects: manufacturing orders may belong to a project, but projects remain the higher-level long-running record
|
||||
- Inventory: manufacturing consumes components and produces stock through real issue/receipt transactions
|
||||
- Dashboard: manufacturing now contributes released/open/overdue load widgets
|
||||
|
||||
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 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 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, 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:
|
||||
|
||||
- Purchasing: shortages, late receipts, and vendor risk should surface directly in planning
|
||||
- Manufacturing: routings, work centers, and capacity should deepen the schedule model
|
||||
- Projects: richer milestones and dependency editing should extend the project-level timeline
|
||||
|
||||
## Workspace
|
||||
|
||||
- `client`: React, Vite, Tailwind frontend
|
||||
- `server`: Express API, Prisma, auth/RBAC, file storage, PDF rendering
|
||||
- `shared`: shared TypeScript contracts and constants
|
||||
|
||||
## Local development
|
||||
## Local Development
|
||||
|
||||
1. Use Node.js 22 for local development if you want Prisma migration commands to behave the same way as Docker.
|
||||
2. Install dependencies with `npm.cmd install`.
|
||||
@@ -43,24 +203,225 @@ 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:
|
||||
|
||||
## Persistence and backup
|
||||
```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.
|
||||
|
||||
## Persistence And Backup
|
||||
|
||||
- SQLite database path: `/app/data/prisma/app.db`
|
||||
- Uploaded files: `/app/data/uploads`
|
||||
- Backup the entire mounted `/app/data` volume to preserve both records and attachments.
|
||||
|
||||
## CRM
|
||||
|
||||
The current CRM foundation supports:
|
||||
|
||||
- customer and vendor list, detail, create, and edit flows
|
||||
- search by text plus status and state/province filters
|
||||
- customer reseller flag, reseller discount, and parent-child hierarchy
|
||||
- contact-history timeline entries for notes, calls, emails, and meetings
|
||||
- multiple account contacts with role and primary-contact tracking
|
||||
- shared file attachments on customer and vendor records
|
||||
- commercial terms fields including payment terms, currency, tax exempt, and credit hold
|
||||
|
||||
QOL direction:
|
||||
|
||||
- saved filters and quick views
|
||||
- cleaner hierarchy navigation for reseller/customer trees
|
||||
- richer downstream rollups once purchasing and shipping grow further
|
||||
|
||||
Recent CRM features depend on the committed Prisma migrations being applied. If you update the code and do not run migrations, the UI may render fields that are not yet present in the database.
|
||||
|
||||
## Inventory
|
||||
|
||||
The current inventory foundation supports:
|
||||
|
||||
- protected item master list, detail, create, and edit flows
|
||||
- SKU, description, type, status, unit-of-measure, sellable/purchasable, default cost, default price, and notes fields
|
||||
- BOM header and BOM line editing directly on the item form
|
||||
- searchable component lookup for BOM lines, designed for large item catalogs
|
||||
- SKU-first searchable component lookup for BOM lines, with SKU shown in the picker and description kept separate in the selected row
|
||||
- BOM detail display with component SKU, name, quantity, unit, notes, and position
|
||||
- protected warehouse list, detail, create, and edit flows
|
||||
- nested stock-location management inside each warehouse record
|
||||
- inventory transaction posting for receipts, issues, and adjustments
|
||||
- inventory transfers with paired source/destination movement posting
|
||||
- manual reservations plus automatic work-order component reservations
|
||||
- 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
|
||||
- fresh bootstrap starts inventory and warehouse data empty so first-run environments do not include demo operational records
|
||||
|
||||
QOL direction:
|
||||
|
||||
- clearer warehouse dashboards and shortage views
|
||||
- BOM revisions and where-used visibility
|
||||
|
||||
This module introduces `inventory.read` and `inventory.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||
|
||||
`defaultPrice` now flows from inventory items into quote and sales-order line entry when a SKU is selected. Users can still override the line price after selection.
|
||||
|
||||
## Sales
|
||||
|
||||
The current sales foundation supports:
|
||||
|
||||
- quote and sales-order list, detail, create, and edit flows
|
||||
- searchable customer lookup instead of static customer dropdowns
|
||||
- SKU-searchable line entry for quote and order lines
|
||||
- document-level discount, tax, freight, subtotal, and total calculations
|
||||
- reseller discount defaulting from customer records into sales documents
|
||||
- status quick actions directly from quote and order detail pages
|
||||
- quote conversion into a sales order
|
||||
- line-level unit prices populated from the selected inventory item default price
|
||||
- branded quote and sales-order PDFs through the shared document pipeline
|
||||
- approval stamps and revision history directly on quote and sales-order detail pages
|
||||
- revision-reason capture when editing customer-facing sales documents
|
||||
|
||||
QOL direction:
|
||||
|
||||
- line duplication and faster keyboard-heavy line editing
|
||||
- revision comparison view and restore-style workflows
|
||||
- richer PDF output for quotes and sales orders
|
||||
|
||||
This module introduces `sales.read` and `sales.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||
|
||||
## Purchasing
|
||||
|
||||
The current purchasing foundation supports:
|
||||
|
||||
- purchase-order list, detail, create, and edit flows
|
||||
- searchable vendor lookup instead of a static vendor dropdown
|
||||
- SKU-searchable line entry for purchase-order lines
|
||||
- purchase-order item lookup restricted to inventory items flagged as purchasable
|
||||
- receiving workflow tied to purchase orders, with receipt history and inventory posting
|
||||
- document-level tax, freight, subtotal, and total calculations
|
||||
- quick status actions directly from purchase-order detail pages
|
||||
- vendor payment terms and currency surfaced on purchase-order forms and details
|
||||
- branded purchase-order PDFs through the shared document pipeline
|
||||
|
||||
QOL direction:
|
||||
|
||||
- richer dashboard widgets for vendor queues and inbound material exceptions
|
||||
- vendor-side exception tracking around acknowledgements, invoice matching, and receipt discrepancies
|
||||
|
||||
This module introduces `purchasing.read` and `purchasing.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||
|
||||
## Shipping
|
||||
|
||||
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
|
||||
- branded packing-slip PDF rendering from shipment detail pages
|
||||
- branded shipping-label and bill-of-lading PDF rendering from shipment detail pages
|
||||
- logistics attachments directly on shipment records
|
||||
|
||||
QOL direction:
|
||||
|
||||
- reprint/history actions for generated logistics PDFs
|
||||
- partial-shipment and split-shipment UX
|
||||
|
||||
This module introduces `shipping.read` and `shipping.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||
|
||||
Moving forward, any UI that requires searching for records or items should use a searchable picker/autocomplete rather than a static dropdown. Filter controls can remain dropdowns, but non-filter lookup fields such as SKU pickers, customer selectors, vendor selectors, and similar operational search inputs should not be implemented as long static selects.
|
||||
|
||||
## UI Density
|
||||
|
||||
The active client screens have been normalized toward a denser workspace layout:
|
||||
|
||||
- form controls and action bars use tighter padding
|
||||
- CRM, inventory, warehouse, settings, dashboard, and login screens use reduced card spacing
|
||||
- headings, metric cards, empty states, and long-text blocks were tightened for better data density
|
||||
|
||||
This denser layout is now the baseline for future screens. New pages should avoid reverting to oversized card padding, tall action bars, or long static dropdowns for operational datasets.
|
||||
|
||||
## Branding
|
||||
|
||||
Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand.
|
||||
|
||||
Logo uploads are stored through the authenticated file pipeline and are rendered back into the settings UI through an authenticated blob fetch, so image preview works after save and refresh.
|
||||
|
||||
## Migrations
|
||||
|
||||
- Create a local migration: `npm run prisma:migrate`
|
||||
- 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.
|
||||
|
||||
## PDF generation
|
||||
As of March 15, 2026, the latest committed domain migrations include:
|
||||
|
||||
- CRM status and list filters
|
||||
- CRM contact-history timeline
|
||||
- reseller hierarchy and reseller discount support
|
||||
- CRM commercial terms and account contacts
|
||||
- CRM lifecycle stages and operational metadata
|
||||
- inventory item master and BOM foundation
|
||||
- warehouse and stock-location foundation
|
||||
- inventory transactions and on-hand tracking
|
||||
- sales quote and sales-order foundation
|
||||
- purchase-order foundation
|
||||
- purchase receiving foundation
|
||||
- branded sales and purchasing PDF templates
|
||||
- sales approvals and document revision history
|
||||
- inventory default price support
|
||||
- sales totals and commercial fields
|
||||
- shipping foundation
|
||||
- projects foundation
|
||||
- manufacturing 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, 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 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.
|
||||
|
||||
Puppeteer is used by the backend to render HTML templates into professional PDFs. The Docker image includes Chromium runtime dependencies required for headless execution.
|
||||
|
||||
184
ROADMAP.md
184
ROADMAP.md
@@ -1,99 +1,155 @@
|
||||
# Roadmap
|
||||
|
||||
## 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
|
||||
- SVAR Gantt integration wrapper with demo planning data
|
||||
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||
- 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
|
||||
|
||||
- Docker runtime has been authored but not validated in this environment because the local Docker daemon was unavailable
|
||||
- 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 is currently read-focused and seeded; create/update/detail workflows still need to be built
|
||||
### Platform and operational docs
|
||||
|
||||
## Planned feature phases
|
||||
- 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
|
||||
|
||||
### Phase 1: CRM and master data hardening
|
||||
### Dashboard
|
||||
|
||||
- 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
|
||||
|
||||
### CRM and master data
|
||||
|
||||
- Customer and vendor create/edit/detail pages
|
||||
- Search, filters, and status tagging
|
||||
- Contact history and internal notes
|
||||
- Shared attachment support on CRM entities
|
||||
- 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 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
|
||||
- Warehouse and stock location modeling
|
||||
- Inventory transactions and on-hand tracking
|
||||
- Bills of materials and custom assemblies
|
||||
- Project records tied to manufacturing work
|
||||
- File attachments for BOM drawings and manufacturing support docs
|
||||
- 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
|
||||
### Sales and purchasing
|
||||
|
||||
- Quotes, sales orders, and purchase orders
|
||||
- Reusable line-item and totals model
|
||||
- Document states, approvals, and revision history
|
||||
- Branded PDF templates rendered through Puppeteer
|
||||
- Attachments for vendor invoices and supporting documents
|
||||
- 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
|
||||
- Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions
|
||||
- Better totals breakdown visibility on list pages and detail pages
|
||||
- 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
|
||||
|
||||
### Phase 5: Manufacturing planning and scheduling
|
||||
### Shipping and logistics
|
||||
|
||||
- Live project-backed SVAR gantt timelines
|
||||
- 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
|
||||
- Printer-friendly reprint/history actions for logistics documents
|
||||
|
||||
### Projects and program management
|
||||
|
||||
- Project document hub for drawings, support files, correspondence, and revision references
|
||||
- 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
|
||||
- Project templates for repeatable build types
|
||||
- Project-specific attachment bundles and revision snapshots
|
||||
- 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
|
||||
|
||||
### Manufacturing execution
|
||||
|
||||
- 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
|
||||
- Traveler/job packet output
|
||||
- Partial completions and split-order execution visibility
|
||||
- Better shortage and substitute-part handling
|
||||
- Shop-floor quick actions and dense tablet-friendly execution views
|
||||
- Rework / hold / scrap tracking
|
||||
- Work-center dashboards and operator-focused queues
|
||||
|
||||
### Planning and scheduling
|
||||
|
||||
- Standardize dense UI primitives and shared page shells so future Workbench, dashboard, and operational screens reuse the same cards, filter bars, empty states, and section wrappers instead of reintroducing ad hoc layout patterns
|
||||
- Task dependencies, milestones, and progress updates
|
||||
- Manufacturing calendar views and bottleneck visibility
|
||||
- Labor and machine scheduling support
|
||||
- Theme-compliant gantt customization for light/dark mode
|
||||
- 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
|
||||
- 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
|
||||
- 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 6: 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
|
||||
|
||||
### Security, audit, and operations maturity
|
||||
|
||||
- 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
|
||||
- Backup verification and restore-drill guidance should keep expanding as the system grows
|
||||
|
||||
## Cross-cutting improvements
|
||||
|
||||
- Stronger validation and error reporting across all APIs
|
||||
- More automated tests for auth, settings, files, PDFs, and workflow modules
|
||||
- Better mobile behavior in module-level pages
|
||||
- Ongoing responsive-density tuning for module-level layouts and data-entry screens
|
||||
- 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
|
||||
|
||||
1. CRM detail and edit workflows
|
||||
2. Inventory item and BOM data model
|
||||
3. Sales order and quote foundation
|
||||
4. Shipping module tied to sales orders
|
||||
5. Live manufacturing gantt scheduling
|
||||
- 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
18
STRUCTURE.md
18
STRUCTURE.md
@@ -1,5 +1,10 @@
|
||||
# Project Structure
|
||||
|
||||
## Documentation maintenance
|
||||
|
||||
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated when structural or implementation changes materially affect shipped behavior.
|
||||
- If structure guidance changes, update the related source-of-truth docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), and [AGENTS.md](D:/CODING/mrp-codex/AGENTS.md) as needed.
|
||||
|
||||
## Top-level layout
|
||||
|
||||
- `client/`: frontend application
|
||||
@@ -15,12 +20,23 @@
|
||||
- Keep reusable UI primitives in `src/components`.
|
||||
- Theme state and brand tokens belong in `src/theme`.
|
||||
- PDF screen components must remain separate from API-rendered document templates.
|
||||
- Treat `src/modules/dashboard` as a long-lived operational module. New high-level KPI, alert, queue, and shortcut surfaces should compose into it rather than spawning disconnected landing pages.
|
||||
- Any non-filter lookup UI must be implemented as a searchable picker or autocomplete; do not use long static dropdowns for operational datasets such as items, customers, vendors, or document-linked records.
|
||||
- Inventory items expose both cost and sell price. Downstream sales document entry should default from the item price field rather than requiring duplicate price maintenance.
|
||||
- Future vendor-facing purchasing flows should follow the same searchable-lookup rule and shared document/totals model already used by sales.
|
||||
- Purchase-order item pickers must only surface inventory items flagged as purchasable.
|
||||
- Shipping, sales, and future purchasing PDFs should be rendered through the backend documents module and shared Puppeteer pipeline rather than ad hoc frontend-only exports.
|
||||
- Preserve the current dense operations UI style on active module pages: compact controls, tighter card padding, and shorter empty states unless a screen has a clear reason to be more spacious.
|
||||
- Treat `projects` as its own long-lived domain under both client and server. It should continue integrating with CRM, sales, inventory, purchasing, shipping, and planning rather than living inside only one of those modules.
|
||||
- Treat `manufacturing` as a separate long-lived domain from `projects`; work orders, routings, labor capture, WIP, and shop-floor execution should not be modeled only as project fields.
|
||||
- Treat `planning` as the scheduling/visibility layer that consumes project and manufacturing data rather than replacing either domain.
|
||||
- When adding a new top-level module to the shell, add a lightweight SVG icon in the navigation config so desktop and mobile nav stay aligned.
|
||||
|
||||
## Backend rules
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
23
UNRAID.md
23
UNRAID.md
@@ -1,8 +1,13 @@
|
||||
# Unraid Install Guide
|
||||
|
||||
## Documentation maintenance
|
||||
|
||||
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated when deployment behavior, startup flow, persistence expectations, or operator-facing install steps materially change.
|
||||
- If Unraid deployment guidance changes, update the companion project docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), and [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) in the same change set when relevant.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -11,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`
|
||||
@@ -20,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.
|
||||
|
||||
@@ -101,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.
|
||||
@@ -123,7 +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, manufacturing work orders, audit tooling, and persisted auth sessions. Let the container complete startup migrations before testing new screens.
|
||||
|
||||
## Backup guidance
|
||||
|
||||
@@ -141,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +1,267 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
const links = [
|
||||
{ to: "/", label: "Overview" },
|
||||
{ to: "/settings/company", label: "Company Settings" },
|
||||
{ to: "/crm/customers", label: "Customers" },
|
||||
{ to: "/crm/vendors", label: "Vendors" },
|
||||
{ to: "/planning/gantt", label: "Gantt" },
|
||||
{ to: "/", label: "Dashboard", icon: <DashboardIcon /> },
|
||||
{ to: "/settings/company", label: "Company Settings", icon: <CompanyIcon /> },
|
||||
{ to: "/crm/customers", label: "Customers", icon: <CustomersIcon /> },
|
||||
{ to: "/crm/vendors", label: "Vendors", icon: <VendorsIcon /> },
|
||||
{ to: "/inventory/items", label: "Inventory", icon: <InventoryIcon /> },
|
||||
{ to: "/inventory/warehouses", label: "Warehouses", icon: <WarehouseIcon /> },
|
||||
{ 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/workbench", label: "Workbench", icon: <WorkbenchIcon /> },
|
||||
];
|
||||
|
||||
function NavIcon({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
|
||||
{children}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M4 4h7v7H4z" />
|
||||
<path d="M13 4h7v4h-7z" />
|
||||
<path d="M13 10h7v10h-7z" />
|
||||
<path d="M4 13h7v7H4z" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M4 20h16" />
|
||||
<path d="M6 20V8l6-4 6 4v12" />
|
||||
<path d="M9 12h.01" />
|
||||
<path d="M15 12h.01" />
|
||||
<path d="M12 20v-4" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomersIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
||||
<path d="M16 13a2.5 2.5 0 1 0 0-5" />
|
||||
<path d="M3.5 19a4.5 4.5 0 0 1 9 0" />
|
||||
<path d="M14 18a3.5 3.5 0 0 1 6 0" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorsIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M4 20h16" />
|
||||
<path d="M6 20V7h12v13" />
|
||||
<path d="M9 10h.01" />
|
||||
<path d="M12 10h.01" />
|
||||
<path d="M15 10h.01" />
|
||||
<path d="M9 14h.01" />
|
||||
<path d="M12 14h.01" />
|
||||
<path d="M15 14h.01" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M4 8l8-4 8 4-8 4-8-4Z" />
|
||||
<path d="M4 8v8l8 4 8-4V8" />
|
||||
<path d="M12 12v8" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function WarehouseIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M3 10 12 4l9 6" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
<path d="M9 20v-5h6v5" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M7 4h8l4 4v12H7z" />
|
||||
<path d="M15 4v4h4" />
|
||||
<path d="M10 12h6" />
|
||||
<path d="M10 16h4" />
|
||||
<path d="M5 8H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h8" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function SalesOrderIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M7 4h10l3 3v13H7z" />
|
||||
<path d="M17 4v3h3" />
|
||||
<path d="M10 11h7" />
|
||||
<path d="M10 15h7" />
|
||||
<path d="M10 19h5" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseOrderIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M7 4h10l3 3v13H7z" />
|
||||
<path d="M17 4v3h3" />
|
||||
<path d="M10 11h7" />
|
||||
<path d="M10 15h4" />
|
||||
<path d="M15.5 17.5 18 20l3-4" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function ShipmentIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M3 8h11v8H3z" />
|
||||
<path d="M14 11h3l3 3v2h-6" />
|
||||
<path d="M7 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
||||
<path d="M17 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
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" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 18h10" />
|
||||
<rect x="10" y="4" width="7" height="4" rx="1.5" />
|
||||
<rect x="7" y="16" width="9" height="4" rx="1.5" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectsIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M5 6h6" />
|
||||
<path d="M5 12h14" />
|
||||
<path d="M5 18h8" />
|
||||
<rect x="12" y="4" width="7" height="4" rx="1.5" />
|
||||
<rect x="9" y="16" width="9" height="4" rx="1.5" />
|
||||
<path d="M12 8v8" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function ManufacturingIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<circle cx="8" cy="16" r="2" />
|
||||
<circle cx="16" cy="16" r="2" />
|
||||
<path d="M8 14V8l4-2 4 2v6" />
|
||||
<path d="M12 10h6" />
|
||||
<path d="M18 8v4" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-6 md:px-8">
|
||||
<div className="mx-auto flex max-w-7xl gap-6">
|
||||
<aside className="hidden w-72 shrink-0 flex-col rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel backdrop-blur md:flex">
|
||||
<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-3 text-2xl 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-8 space-y-2">
|
||||
<nav className="mt-4 space-y-1.5">
|
||||
{links.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-2xl px-4 py-3 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"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{link.icon}
|
||||
{link.label}
|
||||
</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-6 flex items-center justify-between rounded-[28px] border border-line/70 bg-surface/90 px-6 py-5 shadow-panel backdrop-blur">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Operations Command</p>
|
||||
<h2 className="text-2xl font-bold text-text">Foundation Console</h2>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<nav className="mb-6 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 }) =>
|
||||
`whitespace-nowrap 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"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{link.icon}
|
||||
{link.label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
221
client/src/components/FileAttachmentsPanel.tsx
Normal file
221
client/src/components/FileAttachmentsPanel.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { FileAttachmentDto } from "@mrp/shared";
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { ConfirmActionDialog } from "./ConfirmActionDialog";
|
||||
|
||||
interface FileAttachmentsPanelProps {
|
||||
ownerType: string;
|
||||
ownerId: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emptyMessage: string;
|
||||
onAttachmentCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
function formatFileSize(sizeBytes: number) {
|
||||
if (sizeBytes < 1024) {
|
||||
return `${sizeBytes} B`;
|
||||
}
|
||||
|
||||
if (sizeBytes < 1024 * 1024) {
|
||||
return `${(sizeBytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function FileAttachmentsPanel({
|
||||
ownerType,
|
||||
ownerId,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
emptyMessage,
|
||||
onAttachmentCountChange,
|
||||
}: FileAttachmentsPanelProps) {
|
||||
const { token, user } = useAuth();
|
||||
const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]);
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canReadFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getAttachments(token, ownerType, ownerId)
|
||||
.then((nextAttachments) => {
|
||||
setAttachments(nextAttachments);
|
||||
onAttachmentCountChange?.(nextAttachments.length);
|
||||
setStatus(nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load attachments.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [canReadFiles, onAttachmentCountChange, ownerId, ownerType, token]);
|
||||
|
||||
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !token || !canWriteFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setStatus("Uploading attachment...");
|
||||
|
||||
try {
|
||||
const attachment = await api.uploadFile(token, file, ownerType, ownerId);
|
||||
setAttachments((current) => {
|
||||
const nextAttachments = [attachment, ...current];
|
||||
onAttachmentCountChange?.(nextAttachments.length);
|
||||
return nextAttachments;
|
||||
});
|
||||
setStatus("Attachment uploaded.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to upload attachment.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpen(attachment: FileAttachmentDto) {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await api.getFileContentBlob(token, attachment.id);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to open attachment.";
|
||||
setStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(attachment: FileAttachmentDto) {
|
||||
if (!token || !canWriteFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingAttachmentId(attachment.id);
|
||||
setStatus(`Deleting ${attachment.originalName}...`);
|
||||
|
||||
try {
|
||||
await api.deleteAttachment(token, attachment.id);
|
||||
setAttachments((current) => {
|
||||
const nextAttachments = current.filter((item) => item.id !== attachment.id);
|
||||
onAttachmentCountChange?.(nextAttachments.length);
|
||||
return nextAttachments;
|
||||
});
|
||||
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="surface-panel min-w-0">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<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">
|
||||
{isUploading ? "Uploading..." : "Upload file"}
|
||||
<input className="hidden" type="file" onChange={handleUpload} disabled={isUploading} />
|
||||
</label>
|
||||
) : null}
|
||||
</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-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-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-3 space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
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()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpen(attachment)}
|
||||
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
{canWriteFiles ? (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{deletingAttachmentId === attachment.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmActionDialog
|
||||
open={attachmentPendingDelete != null}
|
||||
title="Delete attachment"
|
||||
description={
|
||||
attachmentPendingDelete
|
||||
? `Delete ${attachmentPendingDelete.originalName} from this record.`
|
||||
: "Delete this attachment."
|
||||
}
|
||||
impact="The file link will be removed from this record immediately."
|
||||
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
|
||||
confirmLabel="Delete file"
|
||||
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
|
||||
onClose={() => {
|
||||
if (!deletingAttachmentId) {
|
||||
setAttachmentPendingDelete(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (attachmentPendingDelete) {
|
||||
await handleDelete(attachmentPendingDelete);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,15 +1,46 @@
|
||||
@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";
|
||||
--color-brand: 24 90 219;
|
||||
--color-accent: 0 166 166;
|
||||
--color-surface: 244 247 251;
|
||||
--color-surface-brand: 244 247 251;
|
||||
--color-surface: var(--color-surface-brand);
|
||||
--color-page: 248 250 252;
|
||||
--color-text: 15 23 42;
|
||||
--color-muted: 90 106 133;
|
||||
|
||||
@@ -1,13 +1,123 @@
|
||||
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,
|
||||
GanttLinkDto,
|
||||
GanttTaskDto,
|
||||
PlanningTimelineDto,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
} from "@mrp/shared";
|
||||
import type {
|
||||
CrmContactDto,
|
||||
CrmContactInput,
|
||||
CrmContactEntryDto,
|
||||
CrmContactEntryInput,
|
||||
CrmCustomerHierarchyOptionDto,
|
||||
CrmRecordDetailDto,
|
||||
CrmRecordInput,
|
||||
CrmLifecycleStage,
|
||||
CrmRecordStatus,
|
||||
CrmRecordSummaryDto,
|
||||
} from "@mrp/shared/dist/crm/types.js";
|
||||
import type {
|
||||
InventoryItemDetailDto,
|
||||
InventoryItemInput,
|
||||
InventoryItemOptionDto,
|
||||
InventorySkuBuilderPreviewDto,
|
||||
InventorySkuCatalogTreeDto,
|
||||
InventorySkuFamilyDto,
|
||||
InventorySkuFamilyInput,
|
||||
InventorySkuNodeDto,
|
||||
InventorySkuNodeInput,
|
||||
InventoryReservationInput,
|
||||
InventoryItemStatus,
|
||||
InventoryItemSummaryDto,
|
||||
InventoryTransferInput,
|
||||
InventoryTransactionInput,
|
||||
InventoryItemType,
|
||||
WarehouseDetailDto,
|
||||
WarehouseInput,
|
||||
WarehouseLocationOptionDto,
|
||||
WarehouseSummaryDto,
|
||||
} from "@mrp/shared/dist/inventory/types.js";
|
||||
import type {
|
||||
ManufacturingStationDto,
|
||||
ManufacturingStationInput,
|
||||
ManufacturingItemOptionDto,
|
||||
ManufacturingProjectOptionDto,
|
||||
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,
|
||||
ProjectStatus,
|
||||
ProjectSummaryDto,
|
||||
} from "@mrp/shared/dist/projects/types.js";
|
||||
import type {
|
||||
SalesCustomerOptionDto,
|
||||
DemandPlanningRollupDto,
|
||||
SalesDocumentDetailDto,
|
||||
SalesDocumentInput,
|
||||
SalesOrderPlanningDto,
|
||||
SalesDocumentRevisionDto,
|
||||
SalesDocumentStatus,
|
||||
SalesDocumentSummaryDto,
|
||||
} from "@mrp/shared/dist/sales/types.js";
|
||||
import type {
|
||||
PurchaseOrderDetailDto,
|
||||
PurchaseOrderInput,
|
||||
PurchaseOrderRevisionDto,
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderSummaryDto,
|
||||
PurchaseVendorOptionDto,
|
||||
} from "@mrp/shared";
|
||||
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||
import type {
|
||||
ShipmentDetailDto,
|
||||
ShipmentInput,
|
||||
ShipmentOrderOptionDto,
|
||||
ShipmentPickInput,
|
||||
ShipmentStatus,
|
||||
ShipmentSummaryDto,
|
||||
} from "@mrp/shared/dist/shipping/types.js";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
@@ -33,6 +143,18 @@ async function request<T>(input: string, init?: RequestInit, token?: string): Pr
|
||||
return json.data;
|
||||
}
|
||||
|
||||
function buildQueryString(params: Record<string, string | undefined>) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : "";
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login(payload: LoginRequest) {
|
||||
return request<LoginResponse>("/api/v1/auth/login", {
|
||||
@@ -43,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);
|
||||
},
|
||||
@@ -75,14 +264,703 @@ export const api = {
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
getCustomers(token: string) {
|
||||
return request<Array<Record<string, string>>>("/api/v1/crm/customers", undefined, token);
|
||||
async getFileContentBlob(token: string, fileId: string) {
|
||||
const response = await fetch(`/api/v1/files/${fileId}/content`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to load file content.", "FILE_CONTENT_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
getVendors(token: string) {
|
||||
return request<Array<Record<string, string>>>("/api/v1/crm/vendors", undefined, token);
|
||||
getAttachments(token: string, ownerType: string, ownerId: string) {
|
||||
return request<FileAttachmentDto[]>(
|
||||
`/api/v1/files${buildQueryString({
|
||||
ownerType,
|
||||
ownerId,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getGanttDemo(token: string) {
|
||||
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
|
||||
deleteAttachment(token: string, fileId: string) {
|
||||
return request<FileAttachmentDto>(
|
||||
`/api/v1/files/${fileId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
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?: {
|
||||
q?: string;
|
||||
status?: CrmRecordStatus;
|
||||
lifecycleStage?: CrmLifecycleStage;
|
||||
state?: string;
|
||||
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
|
||||
}
|
||||
) {
|
||||
return request<CrmRecordSummaryDto[]>(
|
||||
`/api/v1/crm/customers${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
lifecycleStage: filters?.lifecycleStage,
|
||||
state: filters?.state,
|
||||
flag: filters?.flag,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getCustomer(token: string, customerId: string) {
|
||||
return request<CrmRecordDetailDto>(`/api/v1/crm/customers/${customerId}`, undefined, token);
|
||||
},
|
||||
getCustomerHierarchyOptions(token: string, excludeCustomerId?: string) {
|
||||
return request<CrmCustomerHierarchyOptionDto[]>(
|
||||
`/api/v1/crm/customers/hierarchy-options${buildQueryString({
|
||||
excludeCustomerId,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
createCustomer(token: string, payload: CrmRecordInput) {
|
||||
return request<CrmRecordDetailDto>(
|
||||
"/api/v1/crm/customers",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
updateCustomer(token: string, customerId: string, payload: CrmRecordInput) {
|
||||
return request<CrmRecordDetailDto>(
|
||||
`/api/v1/crm/customers/${customerId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
createCustomerContactEntry(token: string, customerId: string, payload: CrmContactEntryInput) {
|
||||
return request<CrmContactEntryDto>(
|
||||
`/api/v1/crm/customers/${customerId}/contact-history`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
createCustomerContact(token: string, customerId: string, payload: CrmContactInput) {
|
||||
return request<CrmContactDto>(
|
||||
`/api/v1/crm/customers/${customerId}/contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
getVendors(
|
||||
token: string,
|
||||
filters?: {
|
||||
q?: string;
|
||||
status?: CrmRecordStatus;
|
||||
lifecycleStage?: CrmLifecycleStage;
|
||||
state?: string;
|
||||
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
|
||||
}
|
||||
) {
|
||||
return request<CrmRecordSummaryDto[]>(
|
||||
`/api/v1/crm/vendors${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
lifecycleStage: filters?.lifecycleStage,
|
||||
state: filters?.state,
|
||||
flag: filters?.flag,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getVendor(token: string, vendorId: string) {
|
||||
return request<CrmRecordDetailDto>(`/api/v1/crm/vendors/${vendorId}`, undefined, token);
|
||||
},
|
||||
createVendor(token: string, payload: CrmRecordInput) {
|
||||
return request<CrmRecordDetailDto>(
|
||||
"/api/v1/crm/vendors",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
updateVendor(token: string, vendorId: string, payload: CrmRecordInput) {
|
||||
return request<CrmRecordDetailDto>(
|
||||
`/api/v1/crm/vendors/${vendorId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
createVendorContactEntry(token: string, vendorId: string, payload: CrmContactEntryInput) {
|
||||
return request<CrmContactEntryDto>(
|
||||
`/api/v1/crm/vendors/${vendorId}/contact-history`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
createVendorContact(token: string, vendorId: string, payload: CrmContactInput) {
|
||||
return request<CrmContactDto>(
|
||||
`/api/v1/crm/vendors/${vendorId}/contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
getInventoryItems(token: string, filters?: { q?: string; status?: InventoryItemStatus; type?: InventoryItemType }) {
|
||||
return request<InventoryItemSummaryDto[]>(
|
||||
`/api/v1/inventory/items${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
type: filters?.type,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getInventoryItem(token: string, itemId: string) {
|
||||
return request<InventoryItemDetailDto>(`/api/v1/inventory/items/${itemId}`, undefined, token);
|
||||
},
|
||||
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);
|
||||
},
|
||||
createInventoryItem(token: string, payload: InventoryItemInput) {
|
||||
return request<InventoryItemDetailDto>(
|
||||
"/api/v1/inventory/items",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
updateInventoryItem(token: string, itemId: string, payload: InventoryItemInput) {
|
||||
return request<InventoryItemDetailDto>(
|
||||
`/api/v1/inventory/items/${itemId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
createInventoryTransaction(token: string, itemId: string, payload: InventoryTransactionInput) {
|
||||
return request<InventoryItemDetailDto>(
|
||||
`/api/v1/inventory/items/${itemId}/transactions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
createInventoryTransfer(token: string, itemId: string, payload: InventoryTransferInput) {
|
||||
return request<InventoryItemDetailDto>(
|
||||
`/api/v1/inventory/items/${itemId}/transfers`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
createInventoryReservation(token: string, itemId: string, payload: InventoryReservationInput) {
|
||||
return request<InventoryItemDetailDto>(
|
||||
`/api/v1/inventory/items/${itemId}/reservations`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
getWarehouses(token: string) {
|
||||
return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token);
|
||||
},
|
||||
getWarehouse(token: string, warehouseId: string) {
|
||||
return request<WarehouseDetailDto>(`/api/v1/inventory/warehouses/${warehouseId}`, undefined, token);
|
||||
},
|
||||
createWarehouse(token: string, payload: WarehouseInput) {
|
||||
return request<WarehouseDetailDto>(
|
||||
"/api/v1/inventory/warehouses",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
updateWarehouse(token: string, warehouseId: string, payload: WarehouseInput) {
|
||||
return request<WarehouseDetailDto>(
|
||||
`/api/v1/inventory/warehouses/${warehouseId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
getProjects(
|
||||
token: string,
|
||||
filters?: { q?: string; status?: ProjectStatus; priority?: ProjectPriority; customerId?: string; ownerId?: string }
|
||||
) {
|
||||
return request<ProjectSummaryDto[]>(
|
||||
`/api/v1/projects${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
priority: filters?.priority,
|
||||
customerId: filters?.customerId,
|
||||
ownerId: filters?.ownerId,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getProject(token: string, projectId: string) {
|
||||
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, undefined, token);
|
||||
},
|
||||
createProject(token: string, payload: ProjectInput) {
|
||||
return request<ProjectDetailDto>("/api/v1/projects", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
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);
|
||||
},
|
||||
getProjectOwnerOptions(token: string) {
|
||||
return request<ProjectOwnerOptionDto[]>("/api/v1/projects/owners/options", undefined, token);
|
||||
},
|
||||
getProjectQuoteOptions(token: string, customerId?: string) {
|
||||
return request<ProjectDocumentOptionDto[]>(
|
||||
`/api/v1/projects/quotes/options${buildQueryString({ customerId })}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getProjectOrderOptions(token: string, customerId?: string) {
|
||||
return request<ProjectDocumentOptionDto[]>(
|
||||
`/api/v1/projects/orders/options${buildQueryString({ customerId })}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getProjectShipmentOptions(token: string, customerId?: string) {
|
||||
return request<ProjectShipmentOptionDto[]>(
|
||||
`/api/v1/projects/shipments/options${buildQueryString({ customerId })}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getManufacturingItemOptions(token: string) {
|
||||
return request<ManufacturingItemOptionDto[]>("/api/v1/manufacturing/items/options", undefined, token);
|
||||
},
|
||||
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({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
projectId: filters?.projectId,
|
||||
itemId: filters?.itemId,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getWorkOrder(token: string, workOrderId: string) {
|
||||
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, undefined, token);
|
||||
},
|
||||
createWorkOrder(token: string, payload: WorkOrderInput) {
|
||||
return request<WorkOrderDetailDto>("/api/v1/manufacturing/work-orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
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, payload: WorkOrderStatusUpdateInput) {
|
||||
return request<WorkOrderDetailDto>(
|
||||
`/api/v1/manufacturing/work-orders/${workOrderId}/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
|
||||
);
|
||||
},
|
||||
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
|
||||
return request<WorkOrderDetailDto>(
|
||||
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
token
|
||||
);
|
||||
},
|
||||
recordWorkOrderCompletion(token: string, workOrderId: string, payload: WorkOrderCompletionInput) {
|
||||
return request<WorkOrderDetailDto>(
|
||||
`/api/v1/manufacturing/work-orders/${workOrderId}/completions`,
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
token
|
||||
);
|
||||
},
|
||||
getPlanningTimeline(token: string) {
|
||||
return request<PlanningTimelineDto>("/api/v1/gantt/timeline", undefined, token);
|
||||
},
|
||||
getSalesCustomers(token: string) {
|
||||
return request<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);
|
||||
},
|
||||
getPurchaseVendors(token: string) {
|
||||
return request<PurchaseVendorOptionDto[]>("/api/v1/purchasing/vendors/options", undefined, token);
|
||||
},
|
||||
getQuotes(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
|
||||
return request<SalesDocumentSummaryDto[]>(
|
||||
`/api/v1/sales/quotes${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getQuote(token: string, quoteId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, undefined, token);
|
||||
},
|
||||
createQuote(token: string, payload: SalesDocumentInput) {
|
||||
return request<SalesDocumentDetailDto>("/api/v1/sales/quotes", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateQuote(token: string, quoteId: string, payload: SalesDocumentInput) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateQuoteStatus(token: string, quoteId: string, status: SalesDocumentStatus) {
|
||||
return request<SalesDocumentDetailDto>(
|
||||
`/api/v1/sales/quotes/${quoteId}/status`,
|
||||
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||
token
|
||||
);
|
||||
},
|
||||
approveQuote(token: string, quoteId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/approve`, { method: "POST" }, token);
|
||||
},
|
||||
getQuoteRevisions(token: string, quoteId: string) {
|
||||
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/quotes/${quoteId}/revisions`, undefined, token);
|
||||
},
|
||||
convertQuoteToSalesOrder(token: string, quoteId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/convert`, { method: "POST" }, token);
|
||||
},
|
||||
getSalesOrders(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
|
||||
return request<SalesDocumentSummaryDto[]>(
|
||||
`/api/v1/sales/orders${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
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);
|
||||
},
|
||||
updateSalesOrder(token: string, orderId: string, payload: SalesDocumentInput) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateSalesOrderStatus(token: string, orderId: string, status: SalesDocumentStatus) {
|
||||
return request<SalesDocumentDetailDto>(
|
||||
`/api/v1/sales/orders/${orderId}/status`,
|
||||
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||
token
|
||||
);
|
||||
},
|
||||
approveSalesOrder(token: string, orderId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}/approve`, { method: "POST" }, token);
|
||||
},
|
||||
getSalesOrderRevisions(token: string, orderId: string) {
|
||||
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/orders/${orderId}/revisions`, undefined, token);
|
||||
},
|
||||
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) {
|
||||
return request<PurchaseOrderSummaryDto[]>(
|
||||
`/api/v1/purchasing/orders${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
vendorId: filters?.vendorId,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
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);
|
||||
},
|
||||
updatePurchaseOrder(token: string, orderId: string, payload: PurchaseOrderInput) {
|
||||
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updatePurchaseOrderStatus(token: string, orderId: string, status: PurchaseOrderStatus) {
|
||||
return request<PurchaseOrderDetailDto>(
|
||||
`/api/v1/purchasing/orders/${orderId}/status`,
|
||||
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||
token
|
||||
);
|
||||
},
|
||||
createPurchaseReceipt(token: string, orderId: string, payload: PurchaseReceiptInput) {
|
||||
return request<PurchaseOrderDetailDto>(
|
||||
`/api/v1/purchasing/orders/${orderId}/receipts`,
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
token
|
||||
);
|
||||
},
|
||||
getShipmentOrderOptions(token: string) {
|
||||
return request<ShipmentOrderOptionDto[]>("/api/v1/shipping/orders/options", undefined, token);
|
||||
},
|
||||
getShipments(token: string, filters?: { q?: string; status?: ShipmentStatus; salesOrderId?: string }) {
|
||||
return request<ShipmentSummaryDto[]>(
|
||||
`/api/v1/shipping/shipments${buildQueryString({
|
||||
q: filters?.q,
|
||||
status: filters?.status,
|
||||
salesOrderId: filters?.salesOrderId,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getShipment(token: string, shipmentId: string) {
|
||||
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, undefined, token);
|
||||
},
|
||||
createShipment(token: string, payload: ShipmentInput) {
|
||||
return request<ShipmentDetailDto>("/api/v1/shipping/shipments", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateShipment(token: string, shipmentId: string, payload: ShipmentInput) {
|
||||
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateShipmentStatus(token: string, shipmentId: string, status: ShipmentStatus) {
|
||||
return request<ShipmentDetailDto>(
|
||||
`/api/v1/shipping/shipments/${shipmentId}/status`,
|
||||
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||
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: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to render packing slip PDF.", "PACKING_SLIP_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
async getShipmentLabelPdf(token: string, shipmentId: string) {
|
||||
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/shipping-label.pdf`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to render shipping label PDF.", "SHIPPING_LABEL_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
async getShipmentBillOfLadingPdf(token: string, shipmentId: string) {
|
||||
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/bill-of-lading.pdf`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to render bill of lading PDF.", "BILL_OF_LADING_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
async getQuotePdf(token: string, quoteId: string) {
|
||||
const response = await fetch(`/api/v1/documents/sales/quotes/${quoteId}/document.pdf`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to render quote PDF.", "QUOTE_PDF_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
async getSalesOrderPdf(token: string, orderId: string) {
|
||||
const response = await fetch(`/api/v1/documents/sales/orders/${orderId}/document.pdf`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to render sales order PDF.", "SALES_ORDER_PDF_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
async getPurchaseOrderPdf(token: string, orderId: string) {
|
||||
const response = await fetch(`/api/v1/documents/purchasing/orders/${orderId}/document.pdf`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError("Unable to render purchase order PDF.", "PURCHASE_ORDER_PDF_FAILED");
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
async getCompanyProfilePreviewPdf(token: string) {
|
||||
const response = await fetch("/api/v1/documents/company-profile-preview.pdf", {
|
||||
|
||||
@@ -9,17 +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 { CustomersPage } from "./modules/crm/CustomersPage";
|
||||
import { VendorsPage } from "./modules/crm/VendorsPage";
|
||||
import { GanttPage } from "./modules/gantt/GanttPage";
|
||||
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: [
|
||||
@@ -29,18 +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/vendors", element: <VendorsPage /> },
|
||||
{ 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: 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: lazyElement(<ProjectsPage />) },
|
||||
{ path: "/projects/:projectId", element: lazyElement(<ProjectDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />,
|
||||
children: [
|
||||
{ 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: lazyElement(<PurchaseListPage />) },
|
||||
{ path: "/purchasing/orders/:orderId", element: lazyElement(<PurchaseDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
|
||||
children: [
|
||||
{ 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: 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: 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: 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: 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: 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: 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: 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: 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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
21
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal file
21
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
|
||||
interface CrmAttachmentsPanelProps {
|
||||
ownerType: string;
|
||||
ownerId: string;
|
||||
onAttachmentCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function CrmAttachmentsPanel({ ownerType, ownerId, onAttachmentCountChange }: CrmAttachmentsPanelProps) {
|
||||
return (
|
||||
<FileAttachmentsPanel
|
||||
ownerType={ownerType}
|
||||
ownerId={ownerId}
|
||||
eyebrow="Attachments"
|
||||
title="Shared files"
|
||||
description="Drawings, customer markups, vendor documents, and other reference files linked to this record."
|
||||
emptyMessage="No attachments have been added to this record yet."
|
||||
onAttachmentCountChange={onAttachmentCountChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
72
client/src/modules/crm/CrmContactEntryForm.tsx
Normal file
72
client/src/modules/crm/CrmContactEntryForm.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { CrmContactEntryInput } from "@mrp/shared/dist/crm/types.js";
|
||||
|
||||
import { crmContactTypeOptions } from "./config";
|
||||
|
||||
interface CrmContactEntryFormProps {
|
||||
form: CrmContactEntryInput;
|
||||
isSaving: boolean;
|
||||
status: string;
|
||||
onChange: <Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
export function CrmContactEntryForm({ form, isSaving, status, onChange, onSubmit }: CrmContactEntryFormProps) {
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(220px,1.1fr)]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Interaction type</span>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(event) => onChange("type", event.target.value as CrmContactEntryInput["type"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{crmContactTypeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Contact timestamp</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.contactAt.slice(0, 16)}
|
||||
onChange={(event) => onChange("contactAt", new Date(event.target.value).toISOString())}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Summary</span>
|
||||
<input
|
||||
value={form.summary}
|
||||
onChange={(event) => onChange("summary", 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"
|
||||
placeholder="Short headline for the interaction"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Details</span>
|
||||
<textarea
|
||||
value={form.body}
|
||||
onChange={(event) => onChange("body", event.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
placeholder="Capture what happened, follow-ups, and commitments."
|
||||
/>
|
||||
</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>
|
||||
<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..." : "Add entry"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
13
client/src/modules/crm/CrmContactTypeBadge.tsx
Normal file
13
client/src/modules/crm/CrmContactTypeBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CrmContactEntryType } from "@mrp/shared/dist/crm/types.js";
|
||||
|
||||
import { crmContactTypeOptions, crmContactTypePalette } from "./config";
|
||||
|
||||
export function CrmContactTypeBadge({ type }: { type: CrmContactEntryType }) {
|
||||
const label = crmContactTypeOptions.find((option) => option.value === type)?.label ?? type;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${crmContactTypePalette[type]}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
152
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
152
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { CrmContactDto, CrmContactInput } from "@mrp/shared/dist/crm/types.js";
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { crmContactRoleOptions, emptyCrmContactInput, type CrmEntity } from "./config";
|
||||
|
||||
interface CrmContactsPanelProps {
|
||||
entity: CrmEntity;
|
||||
ownerId: string;
|
||||
contacts: CrmContactDto[];
|
||||
onContactsChange: (contacts: CrmContactDto[]) => void;
|
||||
}
|
||||
|
||||
export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }: CrmContactsPanelProps) {
|
||||
const { token, user } = useAuth();
|
||||
const [form, setForm] = useState<CrmContactInput>(emptyCrmContactInput);
|
||||
const [status, setStatus] = useState("Add account contacts for purchasing, AP, shipping, and engineering.");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
|
||||
|
||||
function updateField<Key extends keyof CrmContactInput>(key: Key, value: CrmContactInput[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving contact...");
|
||||
|
||||
try {
|
||||
const nextContact =
|
||||
entity === "customer"
|
||||
? await api.createCustomerContact(token, ownerId, form)
|
||||
: await api.createVendorContact(token, ownerId, form);
|
||||
|
||||
onContactsChange(
|
||||
[nextContact, ...contacts]
|
||||
.sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary) || left.fullName.localeCompare(right.fullName))
|
||||
);
|
||||
setForm({
|
||||
...emptyCrmContactInput,
|
||||
isPrimary: false,
|
||||
});
|
||||
setStatus("Contact added.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save CRM contact.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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-[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-[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}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted lg:text-right">
|
||||
<div>{contact.email}</div>
|
||||
<div>{contact.phone}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<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-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"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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"
|
||||
>
|
||||
{crmContactRoleOptions.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">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"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<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}
|
||||
onChange={(event) => updateField("isPrimary", event.target.checked)}
|
||||
/>
|
||||
<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">
|
||||
<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..." : "Add contact"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
383
client/src/modules/crm/CrmDetailPage.tsx
Normal file
383
client/src/modules/crm/CrmDetailPage.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
|
||||
import type { PurchaseOrderSummaryDto } 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 { CrmAttachmentsPanel } from "./CrmAttachmentsPanel";
|
||||
import { CrmContactsPanel } from "./CrmContactsPanel";
|
||||
import { CrmContactEntryForm } from "./CrmContactEntryForm";
|
||||
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
|
||||
import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
|
||||
import { CrmStatusBadge } from "./CrmStatusBadge";
|
||||
import { type CrmEntity, crmConfigs, emptyCrmContactEntryInput } from "./config";
|
||||
|
||||
interface CrmDetailPageProps {
|
||||
entity: CrmEntity;
|
||||
}
|
||||
|
||||
export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
const { token, user } = useAuth();
|
||||
const { customerId, vendorId } = useParams();
|
||||
const recordId = entity === "customer" ? customerId : vendorId;
|
||||
const config = crmConfigs[entity];
|
||||
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
|
||||
const [relatedPurchaseOrders, setRelatedPurchaseOrders] = useState<PurchaseOrderSummaryDto[]>([]);
|
||||
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
|
||||
const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput);
|
||||
const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account.");
|
||||
const [isSavingContactEntry, setIsSavingContactEntry] = useState(false);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !recordId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
|
||||
|
||||
loadRecord
|
||||
.then((nextRecord) => {
|
||||
setRecord(nextRecord);
|
||||
setStatus(`${config.singularLabel} record loaded.`);
|
||||
setContactEntryStatus("Add a timeline entry for this account.");
|
||||
if (entity === "vendor") {
|
||||
return api.getPurchaseOrders(token, { vendorId: nextRecord.id });
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.then((purchaseOrders) => setRelatedPurchaseOrders(purchaseOrders))
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
});
|
||||
}, [config.singularLabel, entity, recordId, token]);
|
||||
|
||||
if (!record) {
|
||||
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]) {
|
||||
setContactEntryForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleContactEntrySubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token || !recordId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingContactEntry(true);
|
||||
setContactEntryStatus("Saving timeline entry...");
|
||||
|
||||
try {
|
||||
const nextEntry =
|
||||
entity === "customer"
|
||||
? await api.createCustomerContactEntry(token, recordId, contactEntryForm)
|
||||
: await api.createVendorContactEntry(token, recordId, contactEntryForm);
|
||||
|
||||
setRecord((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
contactHistory: [nextEntry, ...current.contactHistory].sort(
|
||||
(left, right) => new Date(right.contactAt).getTime() - new Date(left.contactAt).getTime()
|
||||
),
|
||||
rollups: {
|
||||
lastContactAt: nextEntry.contactAt,
|
||||
contactHistoryCount: (current.rollups?.contactHistoryCount ?? current.contactHistory.length) + 1,
|
||||
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
|
||||
attachmentCount: current.rollups?.attachmentCount ?? 0,
|
||||
childCustomerCount: current.rollups?.childCustomerCount,
|
||||
},
|
||||
}
|
||||
: current
|
||||
);
|
||||
setContactEntryForm({
|
||||
...emptyCrmContactEntryInput,
|
||||
contactAt: new Date().toISOString(),
|
||||
});
|
||||
setContactEntryStatus("Timeline entry added.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save timeline entry.";
|
||||
setContactEntryStatus(message);
|
||||
} finally {
|
||||
setIsSavingContactEntry(false);
|
||||
}
|
||||
}
|
||||
|
||||
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">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">UPDATED {new Date(record.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
to={config.routeBase}
|
||||
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 {config.collectionLabel.toLowerCase()}
|
||||
</Link>
|
||||
{canManage ? (
|
||||
<Link
|
||||
to={`${config.routeBase}/${record.id}/edit`}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Edit {config.singularLabel.toLowerCase()}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<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>
|
||||
<dd className="mt-1 text-sm text-text">{record.email}</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">{record.phone}</dd>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Address</dt>
|
||||
<dd className="mt-1 whitespace-pre-line text-sm text-text">
|
||||
{[record.addressLine1, record.addressLine2, `${record.city}, ${record.state} ${record.postalCode}`, record.country]
|
||||
.filter(Boolean)
|
||||
.join("\n")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial terms</dt>
|
||||
<dd className="mt-2 grid gap-3 text-sm text-text md:grid-cols-2">
|
||||
<div>Payment terms: {record.paymentTerms ?? "Not set"}</div>
|
||||
<div>Currency: {record.currencyCode ?? "USD"}</div>
|
||||
<div>Tax exempt: {record.taxExempt ? "Yes" : "No"}</div>
|
||||
<div>Credit hold: {record.creditHold ? "Yes" : "No"}</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<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>
|
||||
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||
Created {new Date(record.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operational Flags</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.12em]">
|
||||
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Preferred</span> : null}
|
||||
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Strategic</span> : null}
|
||||
{record.requiresApproval ? <span className="rounded-full border border-amber-400/40 px-3 py-1 text-amber-700 dark:text-amber-300">Requires Approval</span> : null}
|
||||
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-3 py-1 text-rose-700 dark:text-rose-300">Blocked</span> : null}
|
||||
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
|
||||
<span className="rounded-full border border-line/70 px-3 py-1 text-muted">Standard</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{entity === "customer" ? (
|
||||
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reseller Profile</p>
|
||||
<div className="mt-3 grid gap-3 text-sm text-text">
|
||||
<div>
|
||||
<span className="font-semibold">Account type:</span>{" "}
|
||||
{record.isReseller ? "Reseller" : record.parentCustomerName ? "End customer" : "Direct customer"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Discount:</span> {(record.resellerDiscountPercent ?? 0).toFixed(2)}%
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Parent reseller:</span> {record.parentCustomerName ?? "None"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Child accounts:</span> {record.childCustomers?.length ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
<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="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="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="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="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-[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">
|
||||
<CrmStatusBadge status={child.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{entity === "vendor" ? (
|
||||
<section className="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-kicker">PURCHASING ACTIVITY</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManage ? (
|
||||
<Link to={`/purchasing/orders/new?vendorId=${record.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
New purchase order
|
||||
</Link>
|
||||
) : null}
|
||||
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Open purchasing
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{relatedPurchaseOrders.length === 0 ? (
|
||||
<div className="mt-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-3 space-y-2">
|
||||
{relatedPurchaseOrders.slice(0, 8).map((order) => (
|
||||
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{order.documentNumber}</div>
|
||||
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} - {order.lineCount} lines</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
<CrmContactsPanel
|
||||
entity={entity}
|
||||
ownerId={record.id}
|
||||
contacts={record.contacts ?? []}
|
||||
onContactsChange={(contacts: CrmContactDto[]) =>
|
||||
setRecord((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
contacts,
|
||||
rollups: {
|
||||
lastContactAt: current.rollups?.lastContactAt ?? null,
|
||||
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
|
||||
contactCount: contacts.length,
|
||||
attachmentCount: current.rollups?.attachmentCount ?? 0,
|
||||
childCustomerCount: current.rollups?.childCustomerCount,
|
||||
},
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
/>
|
||||
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
|
||||
{canManage ? (
|
||||
<article className="surface-panel min-w-0">
|
||||
<p className="section-kicker">CONTACT HISTORY</p>
|
||||
<div className="mt-3">
|
||||
<CrmContactEntryForm
|
||||
form={contactEntryForm}
|
||||
isSaving={isSavingContactEntry}
|
||||
status={contactEntryStatus}
|
||||
onChange={updateContactEntryField}
|
||||
onSubmit={handleContactEntrySubmit}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
<article className="surface-panel min-w-0">
|
||||
<p className="section-kicker">TIMELINE</p>
|
||||
{record.contactHistory.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 contact history recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{record.contactHistory.map((entry) => (
|
||||
<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">
|
||||
<CrmContactTypeBadge type={entry.type} />
|
||||
<h5 className="text-sm font-semibold text-text">{entry.summary}</h5>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{entry.body}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted lg:text-right">
|
||||
<div>{new Date(entry.contactAt).toLocaleString()}</div>
|
||||
<div className="mt-1">{entry.createdBy.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
<CrmAttachmentsPanel
|
||||
ownerType={config.fileOwnerType}
|
||||
ownerId={record.id}
|
||||
onAttachmentCountChange={(attachmentCount) =>
|
||||
setRecord((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
rollups: {
|
||||
lastContactAt: current.rollups?.lastContactAt ?? null,
|
||||
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
|
||||
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
|
||||
attachmentCount,
|
||||
childCustomerCount: current.rollups?.childCustomerCount,
|
||||
},
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
146
client/src/modules/crm/CrmFormPage.tsx
Normal file
146
client/src/modules/crm/CrmFormPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/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 { CrmRecordForm } from "./CrmRecordForm";
|
||||
import { type CrmEntity, crmConfigs, emptyCrmRecordInput } from "./config";
|
||||
|
||||
interface CrmFormPageProps {
|
||||
entity: CrmEntity;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuth();
|
||||
const { customerId, vendorId } = useParams();
|
||||
const recordId = entity === "customer" ? customerId : vendorId;
|
||||
const config = crmConfigs[entity];
|
||||
const [form, setForm] = useState<CrmRecordInput>(emptyCrmRecordInput);
|
||||
const [hierarchyOptions, setHierarchyOptions] = useState<CrmCustomerHierarchyOptionDto[]>([]);
|
||||
const [status, setStatus] = useState(
|
||||
mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...`
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity !== "customer" || !token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getCustomerHierarchyOptions(token, mode === "edit" ? recordId : undefined)
|
||||
.then(setHierarchyOptions)
|
||||
.catch(() => setHierarchyOptions([]));
|
||||
}, [entity, mode, recordId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !token || !recordId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
|
||||
|
||||
loadRecord
|
||||
.then((record) => {
|
||||
setForm({
|
||||
name: record.name,
|
||||
email: record.email,
|
||||
phone: record.phone,
|
||||
addressLine1: record.addressLine1,
|
||||
addressLine2: record.addressLine2,
|
||||
city: record.city,
|
||||
state: record.state,
|
||||
postalCode: record.postalCode,
|
||||
country: record.country,
|
||||
status: record.status,
|
||||
isReseller: record.isReseller ?? false,
|
||||
resellerDiscountPercent: record.resellerDiscountPercent ?? 0,
|
||||
parentCustomerId: record.parentCustomerId ?? null,
|
||||
paymentTerms: record.paymentTerms ?? "Net 30",
|
||||
currencyCode: record.currencyCode ?? "USD",
|
||||
taxExempt: record.taxExempt ?? false,
|
||||
creditHold: record.creditHold ?? false,
|
||||
lifecycleStage: record.lifecycleStage ?? "ACTIVE",
|
||||
preferredAccount: record.preferredAccount ?? false,
|
||||
strategicAccount: record.strategicAccount ?? false,
|
||||
requiresApproval: record.requiresApproval ?? false,
|
||||
blockedAccount: record.blockedAccount ?? false,
|
||||
notes: record.notes,
|
||||
});
|
||||
setStatus(`${config.singularLabel} record loaded.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
});
|
||||
}, [config.singularLabel, entity, mode, recordId, token]);
|
||||
|
||||
function updateField<Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) {
|
||||
setForm((current: CrmRecordInput) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
|
||||
|
||||
try {
|
||||
const savedRecord =
|
||||
entity === "customer"
|
||||
? mode === "create"
|
||||
? await api.createCustomer(token, form)
|
||||
: await api.updateCustomer(token, recordId ?? "", form)
|
||||
: mode === "create"
|
||||
? await api.createVendor(token, form)
|
||||
: await api.updateVendor(token, recordId ?? "", form);
|
||||
|
||||
navigate(`${config.routeBase}/${savedRecord.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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="section-kicker">CRM EDITOR</p>
|
||||
<h3 className="module-title">
|
||||
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
|
||||
</h3>
|
||||
</div>
|
||||
<Link
|
||||
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
|
||||
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-3 surface-panel">
|
||||
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
|
||||
<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"
|
||||
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}` : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
22
client/src/modules/crm/CrmLifecycleBadge.tsx
Normal file
22
client/src/modules/crm/CrmLifecycleBadge.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { CrmLifecycleStage } from "@mrp/shared/dist/crm/types.js";
|
||||
|
||||
import { crmLifecyclePalette } from "./config";
|
||||
|
||||
interface CrmLifecycleBadgeProps {
|
||||
stage: CrmLifecycleStage;
|
||||
}
|
||||
|
||||
const labels: Record<CrmLifecycleStage, string> = {
|
||||
PROSPECT: "Prospect",
|
||||
ACTIVE: "Active",
|
||||
DORMANT: "Dormant",
|
||||
CHURNED: "Churned",
|
||||
};
|
||||
|
||||
export function CrmLifecycleBadge({ stage }: CrmLifecycleBadgeProps) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${crmLifecyclePalette[stage]}`}>
|
||||
{labels[stage]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
209
client/src/modules/crm/CrmListPage.tsx
Normal file
209
client/src/modules/crm/CrmListPage.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { CrmLifecycleStage, CrmRecordStatus, CrmRecordSummaryDto } from "@mrp/shared/dist/crm/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
|
||||
import { CrmStatusBadge } from "./CrmStatusBadge";
|
||||
import { crmLifecycleFilters, crmOperationalFilters, crmStatusFilters, type CrmEntity, crmConfigs } from "./config";
|
||||
|
||||
interface CrmListPageProps {
|
||||
entity: CrmEntity;
|
||||
}
|
||||
|
||||
export function CrmListPage({ entity }: CrmListPageProps) {
|
||||
const { token, user } = useAuth();
|
||||
const config = crmConfigs[entity];
|
||||
const [records, setRecords] = useState<CrmRecordSummaryDto[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [stateFilter, setStateFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | CrmRecordStatus>("ALL");
|
||||
const [lifecycleFilter, setLifecycleFilter] = useState<"ALL" | CrmLifecycleStage>("ALL");
|
||||
const [operationalFilter, setOperationalFilter] = useState<"ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED">(
|
||||
"ALL"
|
||||
);
|
||||
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filters = {
|
||||
q: searchTerm.trim() || undefined,
|
||||
state: stateFilter.trim() || undefined,
|
||||
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||
lifecycleStage: lifecycleFilter === "ALL" ? undefined : lifecycleFilter,
|
||||
flag: operationalFilter === "ALL" ? undefined : operationalFilter,
|
||||
};
|
||||
|
||||
const loadRecords = entity === "customer" ? api.getCustomers(token, filters) : api.getVendors(token, filters);
|
||||
|
||||
loadRecords
|
||||
.then((nextRecords) => {
|
||||
setRecords(nextRecords);
|
||||
setStatus(`${nextRecords.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
});
|
||||
}, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<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">CRM</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"
|
||||
>
|
||||
New {config.singularLabel.toLowerCase()}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<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
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={`Search ${config.collectionLabel.toLowerCase()} by company, email, phone, or location`}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "ALL" | CrmRecordStatus)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{crmStatusFilters.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">Lifecycle</span>
|
||||
<select
|
||||
value={lifecycleFilter}
|
||||
onChange={(event) => setLifecycleFilter(event.target.value as "ALL" | CrmLifecycleStage)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{crmLifecycleFilters.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">State / Province</span>
|
||||
<input
|
||||
value={stateFilter}
|
||||
onChange={(event) => setStateFilter(event.target.value)}
|
||||
placeholder="Filter by region"
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Operational</span>
|
||||
<select
|
||||
value={operationalFilter}
|
||||
onChange={(event) =>
|
||||
setOperationalFilter(event.target.value as "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED")
|
||||
}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{crmOperationalFilters.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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-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-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">Name</th>
|
||||
<th className="px-2 py-2">Status</th>
|
||||
<th className="px-2 py-2">Lifecycle</th>
|
||||
<th className="px-2 py-2">Operational</th>
|
||||
<th className="px-2 py-2">Activity</th>
|
||||
<th className="px-2 py-2">Contact</th>
|
||||
<th className="px-2 py-2">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} className="transition hover:bg-page/70">
|
||||
<td className="px-2 py-2 font-semibold text-text">
|
||||
<Link to={`${config.routeBase}/${record.id}`} className="hover:text-brand">
|
||||
{record.name}
|
||||
</Link>
|
||||
{entity === "customer" && (record.isReseller || record.parentCustomerName) ? (
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs font-medium text-muted">
|
||||
{record.isReseller ? <span>Reseller</span> : null}
|
||||
{record.parentCustomerName ? <span>Child of {record.parentCustomerName}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<CrmStatusBadge status={record.status} />
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">
|
||||
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs text-muted">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Preferred</span> : null}
|
||||
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Strategic</span> : null}
|
||||
{record.requiresApproval ? <span className="rounded-full border border-line/70 px-2 py-1">Approval</span> : null}
|
||||
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-2 py-1 text-rose-600 dark:text-rose-300">Blocked</span> : null}
|
||||
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
|
||||
<span>Standard</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs text-muted">
|
||||
<div>{record.rollups?.contactHistoryCount ?? 0} timeline entries</div>
|
||||
<div>{record.rollups?.attachmentCount ?? 0} attachments</div>
|
||||
{entity === "customer" ? <div>{record.rollups?.childCustomerCount ?? 0} child accounts</div> : null}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">
|
||||
<div>{record.email}</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{record.city}, {record.state}, {record.country}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">{record.phone}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{new Date(record.updatedAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
199
client/src/modules/crm/CrmRecordForm.tsx
Normal file
199
client/src/modules/crm/CrmRecordForm.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
|
||||
|
||||
import { crmLifecycleOptions, crmStatusOptions } from "./config";
|
||||
import type { CrmEntity } from "./config";
|
||||
|
||||
const fields: Array<{
|
||||
key: "name" | "email" | "phone" | "addressLine1" | "addressLine2" | "city" | "state" | "postalCode" | "country";
|
||||
label: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{ key: "name", label: "Company name" },
|
||||
{ key: "email", label: "Email", type: "email" },
|
||||
{ key: "phone", label: "Phone" },
|
||||
{ key: "addressLine1", label: "Address line 1" },
|
||||
{ key: "addressLine2", label: "Address line 2" },
|
||||
{ key: "city", label: "City" },
|
||||
{ key: "state", label: "State / Province" },
|
||||
{ key: "postalCode", label: "Postal code" },
|
||||
{ key: "country", label: "Country" },
|
||||
];
|
||||
|
||||
interface CrmRecordFormProps {
|
||||
entity: CrmEntity;
|
||||
form: CrmRecordInput;
|
||||
hierarchyOptions?: CrmCustomerHierarchyOptionDto[];
|
||||
onChange: <Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) => void;
|
||||
}
|
||||
|
||||
export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }: CrmRecordFormProps) {
|
||||
return (
|
||||
<>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => onChange("status", event.target.value as CrmRecordInput["status"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{crmStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Lifecycle stage</span>
|
||||
<select
|
||||
value={form.lifecycleStage ?? "ACTIVE"}
|
||||
onChange={(event) => onChange("lifecycleStage", event.target.value as CrmRecordInput["lifecycleStage"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{crmLifecycleOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{entity === "customer" ? (
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Reseller account</span>
|
||||
<select
|
||||
value={form.isReseller ? "yes" : "no"}
|
||||
onChange={(event) => onChange("isReseller", event.target.value === "yes")}
|
||||
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="no">Standard customer</option>
|
||||
<option value="yes">Reseller</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Reseller discount %</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
value={form.resellerDiscountPercent ?? 0}
|
||||
disabled={!form.isReseller}
|
||||
onChange={(event) =>
|
||||
onChange("resellerDiscountPercent", event.target.value === "" ? 0 : Number(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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Parent reseller</span>
|
||||
<select
|
||||
value={form.parentCustomerId ?? ""}
|
||||
onChange={(event) => onChange("parentCustomerId", 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="">No parent reseller</option>
|
||||
{hierarchyOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name} {option.isReseller ? "(Reseller)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Payment terms</span>
|
||||
<input
|
||||
value={form.paymentTerms ?? ""}
|
||||
onChange={(event) => onChange("paymentTerms", 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"
|
||||
placeholder="Net 30"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Currency</span>
|
||||
<input
|
||||
value={form.currencyCode ?? "USD"}
|
||||
onChange={(event) => onChange("currencyCode", event.target.value.toUpperCase() || null)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
maxLength={8}
|
||||
/>
|
||||
</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.taxExempt ?? false}
|
||||
onChange={(event) => onChange("taxExempt", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Tax exempt</span>
|
||||
</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.creditHold ?? false}
|
||||
onChange={(event) => onChange("creditHold", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Credit hold</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-4">
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.preferredAccount ?? false}
|
||||
onChange={(event) => onChange("preferredAccount", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Preferred account</span>
|
||||
</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.strategicAccount ?? false}
|
||||
onChange={(event) => onChange("strategicAccount", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Strategic account</span>
|
||||
</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.requiresApproval ?? false}
|
||||
onChange={(event) => onChange("requiresApproval", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Requires approval</span>
|
||||
</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.blockedAccount ?? false}
|
||||
onChange={(event) => onChange("blockedAccount", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Blocked account</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{fields.map((field) => (
|
||||
<label key={String(field.key)} className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">{field.label}</span>
|
||||
<input
|
||||
type={field.type ?? "text"}
|
||||
value={form[field.key]}
|
||||
onChange={(event) => onChange(field.key, 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">Internal notes</span>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(event) => onChange("notes", event.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal file
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CrmRecordStatus } from "@mrp/shared/dist/crm/types.js";
|
||||
|
||||
import { crmStatusOptions, crmStatusPalette } from "./config";
|
||||
|
||||
export function CrmStatusBadge({ status }: { status: CrmRecordStatus }) {
|
||||
const label = crmStatusOptions.find((option) => option.value === status)?.label ?? status;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${crmStatusPalette[status]}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
import { CrmListPage } from "./CrmListPage";
|
||||
|
||||
export function CustomersPage() {
|
||||
const { token } = useAuth();
|
||||
const [customers, setCustomers] = useState<Array<Record<string, string>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
api.getCustomers(token).then(setCustomers);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
|
||||
<h3 className="mt-3 text-2xl font-bold text-text">Customers</h3>
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead className="bg-page/80 text-left text-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Name</th>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Phone</th>
|
||||
<th className="px-4 py-3">Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{customers.map((customer) => (
|
||||
<tr key={customer.id}>
|
||||
<td className="px-4 py-3 font-semibold text-text">{customer.name}</td>
|
||||
<td className="px-4 py-3 text-muted">{customer.email}</td>
|
||||
<td className="px-4 py-3 text-muted">{customer.phone}</td>
|
||||
<td className="px-4 py-3 text-muted">{customer.city}, {customer.state}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return <CrmListPage entity="customer" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
import { CrmListPage } from "./CrmListPage";
|
||||
|
||||
export function VendorsPage() {
|
||||
const { token } = useAuth();
|
||||
const [vendors, setVendors] = useState<Array<Record<string, string>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
api.getVendors(token).then(setVendors);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
|
||||
<h3 className="mt-3 text-2xl font-bold text-text">Vendors</h3>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{vendors.map((vendor) => (
|
||||
<article key={vendor.id} className="rounded-2xl border border-line/70 bg-page/70 p-5">
|
||||
<h4 className="text-lg font-bold text-text">{vendor.name}</h4>
|
||||
<p className="mt-2 text-sm text-muted">{vendor.email}</p>
|
||||
<p className="text-sm text-muted">{vendor.phone}</p>
|
||||
<p className="mt-3 text-sm text-muted">{vendor.city}, {vendor.state}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return <CrmListPage entity="vendor" />;
|
||||
}
|
||||
|
||||
|
||||
159
client/src/modules/crm/config.ts
Normal file
159
client/src/modules/crm/config.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
crmContactRoles,
|
||||
crmContactEntryTypes,
|
||||
crmLifecycleStages,
|
||||
crmRecordStatuses,
|
||||
type CrmContactInput,
|
||||
type CrmContactRole,
|
||||
type CrmContactEntryInput,
|
||||
type CrmContactEntryType,
|
||||
type CrmLifecycleStage,
|
||||
type CrmRecordInput,
|
||||
type CrmRecordStatus,
|
||||
} from "@mrp/shared/dist/crm/types.js";
|
||||
|
||||
export type CrmEntity = "customer" | "vendor";
|
||||
|
||||
interface CrmModuleConfig {
|
||||
entity: CrmEntity;
|
||||
collectionLabel: string;
|
||||
singularLabel: string;
|
||||
routeBase: string;
|
||||
fileOwnerType: string;
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
export const crmConfigs: Record<CrmEntity, CrmModuleConfig> = {
|
||||
customer: {
|
||||
entity: "customer",
|
||||
collectionLabel: "Customers",
|
||||
singularLabel: "Customer",
|
||||
routeBase: "/crm/customers",
|
||||
fileOwnerType: "crm-customer",
|
||||
emptyMessage: "No customer accounts have been added yet.",
|
||||
},
|
||||
vendor: {
|
||||
entity: "vendor",
|
||||
collectionLabel: "Vendors",
|
||||
singularLabel: "Vendor",
|
||||
routeBase: "/crm/vendors",
|
||||
fileOwnerType: "crm-vendor",
|
||||
emptyMessage: "No vendor records have been added yet.",
|
||||
},
|
||||
};
|
||||
|
||||
export const emptyCrmRecordInput: CrmRecordInput = {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postalCode: "",
|
||||
country: "USA",
|
||||
status: "ACTIVE",
|
||||
notes: "",
|
||||
isReseller: false,
|
||||
resellerDiscountPercent: 0,
|
||||
parentCustomerId: null,
|
||||
paymentTerms: "Net 30",
|
||||
currencyCode: "USD",
|
||||
taxExempt: false,
|
||||
creditHold: false,
|
||||
lifecycleStage: "ACTIVE",
|
||||
preferredAccount: false,
|
||||
strategicAccount: false,
|
||||
requiresApproval: false,
|
||||
blockedAccount: false,
|
||||
};
|
||||
|
||||
export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [
|
||||
{ value: "LEAD", label: "Lead" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "ON_HOLD", label: "On Hold" },
|
||||
{ value: "INACTIVE", label: "Inactive" },
|
||||
];
|
||||
|
||||
export const crmStatusFilters: Array<{ value: "ALL" | CrmRecordStatus; label: string }> = [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...crmStatusOptions,
|
||||
];
|
||||
|
||||
export const crmLifecycleOptions: Array<{ value: CrmLifecycleStage; label: string }> = [
|
||||
{ value: "PROSPECT", label: "Prospect" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "DORMANT", label: "Dormant" },
|
||||
{ value: "CHURNED", label: "Churned" },
|
||||
];
|
||||
|
||||
export const crmLifecycleFilters: Array<{ value: "ALL" | CrmLifecycleStage; label: string }> = [
|
||||
{ value: "ALL", label: "All lifecycle stages" },
|
||||
...crmLifecycleOptions,
|
||||
];
|
||||
|
||||
export const crmOperationalFilters: Array<{
|
||||
value: "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "ALL", label: "All accounts" },
|
||||
{ value: "PREFERRED", label: "Preferred only" },
|
||||
{ value: "STRATEGIC", label: "Strategic only" },
|
||||
{ value: "REQUIRES_APPROVAL", label: "Requires approval" },
|
||||
{ value: "BLOCKED", label: "Blocked only" },
|
||||
];
|
||||
|
||||
export const crmStatusPalette: Record<CrmRecordStatus, string> = {
|
||||
LEAD: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
INACTIVE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
};
|
||||
|
||||
export const crmLifecyclePalette: Record<CrmLifecycleStage, string> = {
|
||||
PROSPECT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
DORMANT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
CHURNED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
|
||||
export const emptyCrmContactEntryInput: CrmContactEntryInput = {
|
||||
type: "NOTE",
|
||||
summary: "",
|
||||
body: "",
|
||||
contactAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export const emptyCrmContactInput: CrmContactInput = {
|
||||
fullName: "",
|
||||
role: "PRIMARY",
|
||||
email: "",
|
||||
phone: "",
|
||||
isPrimary: true,
|
||||
};
|
||||
|
||||
export const crmContactTypeOptions: Array<{ value: CrmContactEntryType; label: string }> = [
|
||||
{ value: "NOTE", label: "Note" },
|
||||
{ value: "CALL", label: "Call" },
|
||||
{ value: "EMAIL", label: "Email" },
|
||||
{ value: "MEETING", label: "Meeting" },
|
||||
];
|
||||
|
||||
export const crmContactTypePalette: Record<CrmContactEntryType, string> = {
|
||||
NOTE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
CALL: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
EMAIL: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||
MEETING: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
|
||||
export const crmContactRoleOptions: Array<{ value: CrmContactRole; label: string }> = [
|
||||
{ value: "PRIMARY", label: "Primary" },
|
||||
{ value: "PURCHASING", label: "Purchasing" },
|
||||
{ value: "AP", label: "Accounts Payable" },
|
||||
{ value: "SHIPPING", label: "Shipping" },
|
||||
{ value: "ENGINEERING", label: "Engineering" },
|
||||
{ value: "SALES", label: "Sales" },
|
||||
{ value: "OTHER", label: "Other" },
|
||||
];
|
||||
|
||||
export { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses };
|
||||
@@ -1,38 +1,590 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { ApiError, api } from "../../lib/api";
|
||||
|
||||
interface DashboardSnapshot {
|
||||
customers: Awaited<ReturnType<typeof api.getCustomers>> | null;
|
||||
vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
|
||||
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
|
||||
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
|
||||
purchaseOrders: Awaited<ReturnType<typeof api.getPurchaseOrders>> | null;
|
||||
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
|
||||
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
|
||||
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;
|
||||
}
|
||||
|
||||
function hasPermission(userPermissions: string[] | undefined, permission: string) {
|
||||
return Boolean(userPermissions?.includes(permission));
|
||||
}
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(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;
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Foundation Status</p>
|
||||
<h3 className="mt-3 text-3xl font-bold text-text">Platform primitives are online.</h3>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-muted">
|
||||
Authentication, RBAC, runtime branding, attachment storage, Docker deployment, and a planning visualization wrapper are now structured for future domain expansion.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white" to="/settings/company">
|
||||
Manage company profile
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-5 py-3 text-sm font-semibold text-text" to="/planning/gantt">
|
||||
Open gantt preview
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roadmap</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
{[
|
||||
"CRM reference entities are seeded and available via protected APIs.",
|
||||
"Company Settings drives runtime brand tokens and PDF identity.",
|
||||
"The next module phase can add BOMs, orders, and shipping documents without app-shell refactors.",
|
||||
].map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-text">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user) {
|
||||
setSnapshot(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const authToken = token;
|
||||
let isMounted = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const canReadCrm = hasPermission(user.permissions, permissions.crmRead);
|
||||
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
|
||||
const canReadPurchasing = hasPermission(user.permissions, permissions.purchasingRead);
|
||||
const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
|
||||
const canReadSales = hasPermission(user.permissions, permissions.salesRead);
|
||||
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
|
||||
const canReadProjects = hasPermission(user.permissions, permissions.projectsRead);
|
||||
|
||||
async function loadSnapshot() {
|
||||
const results = await Promise.allSettled([
|
||||
canReadCrm ? api.getCustomers(authToken) : Promise.resolve(null),
|
||||
canReadCrm ? api.getVendors(authToken) : Promise.resolve(null),
|
||||
canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null),
|
||||
canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null),
|
||||
canReadPurchasing ? api.getPurchaseOrders(authToken) : Promise.resolve(null),
|
||||
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
|
||||
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstRejected = results.find((result) => result.status === "rejected");
|
||||
if (firstRejected?.status === "rejected") {
|
||||
const reason = firstRejected.reason;
|
||||
setError(reason instanceof ApiError ? reason.message : "Unable to load dashboard data.");
|
||||
}
|
||||
|
||||
setSnapshot({
|
||||
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
||||
vendors: results[1].status === "fulfilled" ? results[1].value : null,
|
||||
items: results[2].status === "fulfilled" ? results[2].value : null,
|
||||
warehouses: results[3].status === "fulfilled" ? results[3].value : null,
|
||||
purchaseOrders: results[4].status === "fulfilled" ? results[4].value : null,
|
||||
workOrders: results[5].status === "fulfilled" ? results[5].value : null,
|
||||
quotes: results[6].status === "fulfilled" ? results[6].value : null,
|
||||
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);
|
||||
}
|
||||
|
||||
loadSnapshot().catch((loadError) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(loadError instanceof ApiError ? loadError.message : "Unable to load dashboard data.");
|
||||
setSnapshot(null);
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [token, user]);
|
||||
|
||||
const customers = snapshot?.customers ?? [];
|
||||
const vendors = snapshot?.vendors ?? [];
|
||||
const items = snapshot?.items ?? [];
|
||||
const warehouses = snapshot?.warehouses ?? [];
|
||||
const purchaseOrders = snapshot?.purchaseOrders ?? [];
|
||||
const workOrders = snapshot?.workOrders ?? [];
|
||||
const quotes = snapshot?.quotes ?? [];
|
||||
const orders = snapshot?.orders ?? [];
|
||||
const shipments = snapshot?.shipments ?? [];
|
||||
const projects = snapshot?.projects ?? [];
|
||||
const planningRollup = snapshot?.planningRollup;
|
||||
|
||||
const customerCount = customers.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 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));
|
||||
|
||||
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));
|
||||
|
||||
const shipmentCount = shipments.length;
|
||||
const activeShipmentCount = shipments.filter((shipment) => shipment.status !== "DELIVERED").length;
|
||||
const inTransitCount = shipments.filter((shipment) => shipment.status === "SHIPPED").length;
|
||||
const deliveredCount = shipments.filter((shipment) => shipment.status === "DELIVERED").length;
|
||||
|
||||
const projectCount = projects.length;
|
||||
const activeProjectCount = projects.filter((project) => project.status === "ACTIVE").length;
|
||||
const atRiskProjectCount = projects.filter((project) => project.status === "AT_RISK").length;
|
||||
const overdueProjectCount = projects.filter((project) => {
|
||||
if (!project.dueDate || project.status === "COMPLETE") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Date(project.dueDate).getTime() < Date.now();
|
||||
}).length;
|
||||
|
||||
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: "Accounts",
|
||||
value: snapshot?.customers !== null ? `${customerCount + vendorCount}` : "No access",
|
||||
secondary: snapshot?.customers !== null ? `${activeCustomerCount} active customers` : "",
|
||||
tone: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
label: "Inventory",
|
||||
value: snapshot?.items !== null ? `${itemCount}` : "No access",
|
||||
secondary: snapshot?.items !== null ? `${assemblyCount} buildable items` : "",
|
||||
tone: "bg-sky-500",
|
||||
},
|
||||
{
|
||||
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: "Commercial",
|
||||
value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access",
|
||||
secondary: snapshot?.orders !== null ? `${orderCount} orders live` : "",
|
||||
tone: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
label: "Projects",
|
||||
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
|
||||
secondary: snapshot?.projects !== null ? `${atRiskProjectCount} at risk` : "",
|
||||
tone: "bg-violet-500",
|
||||
},
|
||||
{
|
||||
label: "Readiness",
|
||||
value: planningRollup ? `${shortageItemCount}` : "No access",
|
||||
secondary: planningRollup ? `${totalUncoveredQuantity} units uncovered` : "",
|
||||
tone: "bg-rose-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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">
|
||||
{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>
|
||||
{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="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>
|
||||
</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="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="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>
|
||||
</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="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>
|
||||
</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="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>
|
||||
</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="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="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>
|
||||
</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="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>
|
||||
</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,49 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Gantt } from "@svar-ui/react-gantt";
|
||||
import "@svar-ui/react-gantt/style.css";
|
||||
|
||||
import type { GanttLinkDto, GanttTaskDto } from "@mrp/shared";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
import { useTheme } from "../../theme/ThemeProvider";
|
||||
|
||||
export function GanttPage() {
|
||||
const { token } = useAuth();
|
||||
const { mode } = useTheme();
|
||||
const [tasks, setTasks] = useState<GanttTaskDto[]>([]);
|
||||
const [links, setLinks] = useState<GanttLinkDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getGanttDemo(token).then((data) => {
|
||||
setTasks(data.tasks);
|
||||
setLinks(data.links);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
||||
<h3 className="mt-3 text-2xl font-bold text-text">SVAR Gantt Preview</h3>
|
||||
<p className="mt-2 text-sm text-muted">Theme-aware integration wrapper prepared for future manufacturing schedules and task dependencies.</p>
|
||||
<div
|
||||
className={`gantt-theme mt-6 overflow-hidden rounded-2xl border border-line/70 bg-page/70 p-4 ${
|
||||
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
|
||||
}`}
|
||||
>
|
||||
<Gantt
|
||||
tasks={tasks.map((task) => ({
|
||||
...task,
|
||||
start: new Date(task.start),
|
||||
end: new Date(task.end),
|
||||
}))}
|
||||
links={links}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
client/src/modules/inventory/InventoryAttachmentsPanel.tsx
Normal file
22
client/src/modules/inventory/InventoryAttachmentsPanel.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { inventoryFileOwnerType } from "./config";
|
||||
|
||||
export function InventoryAttachmentsPanel({
|
||||
itemId,
|
||||
onAttachmentCountChange,
|
||||
}: {
|
||||
itemId: string;
|
||||
onAttachmentCountChange?: (count: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<FileAttachmentsPanel
|
||||
ownerType={inventoryFileOwnerType}
|
||||
ownerId={itemId}
|
||||
eyebrow="Attachments"
|
||||
title="Drawings and support docs"
|
||||
description="Store drawings, cut sheets, work instructions, and other manufacturing support files on the item record."
|
||||
emptyMessage="No drawings or support documents have been added to this item yet."
|
||||
onAttachmentCountChange={onAttachmentCountChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
722
client/src/modules/inventory/InventoryDetailPage.tsx
Normal file
722
client/src/modules/inventory/InventoryDetailPage.tsx
Normal file
@@ -0,0 +1,722 @@
|
||||
import type {
|
||||
InventoryItemDetailDto,
|
||||
InventoryReservationInput,
|
||||
InventoryTransactionInput,
|
||||
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 { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { emptyInventoryTransactionInput, inventoryThumbnailOwnerType, inventoryTransactionOptions } from "./config";
|
||||
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
|
||||
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
||||
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
|
||||
import { InventoryTypeBadge } from "./InventoryTypeBadge";
|
||||
|
||||
const emptyTransferInput: InventoryTransferInput = {
|
||||
quantity: 1,
|
||||
fromWarehouseId: "",
|
||||
fromLocationId: "",
|
||||
toWarehouseId: "",
|
||||
toLocationId: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
const emptyReservationInput: InventoryReservationInput = {
|
||||
quantity: 1,
|
||||
warehouseId: null,
|
||||
locationId: null,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export function InventoryDetailPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { itemId } = useParams();
|
||||
const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
|
||||
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||
const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput);
|
||||
const [transferForm, setTransferForm] = useState<InventoryTransferInput>(emptyTransferInput);
|
||||
const [reservationForm, setReservationForm] = useState<InventoryReservationInput>(emptyReservationInput);
|
||||
const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
|
||||
const [transferStatus, setTransferStatus] = useState("Move physical stock between warehouses or locations without manual paired entries.");
|
||||
const [reservationStatus, setReservationStatus] = useState("Reserve stock manually while active work orders reserve component demand automatically.");
|
||||
const [isSavingTransaction, setIsSavingTransaction] = useState(false);
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getInventoryItem(token, itemId)
|
||||
.then((nextItem) => {
|
||||
setItem(nextItem);
|
||||
setStatus("Inventory item loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
|
||||
setStatus(message);
|
||||
});
|
||||
|
||||
api
|
||||
.getWarehouseLocationOptions(token)
|
||||
.then((options) => {
|
||||
setLocationOptions(options);
|
||||
const firstOption = options[0];
|
||||
if (!firstOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTransactionForm((current) => ({
|
||||
...current,
|
||||
warehouseId: current.warehouseId || firstOption.warehouseId,
|
||||
locationId: current.locationId || firstOption.locationId,
|
||||
}));
|
||||
setTransferForm((current) => ({
|
||||
...current,
|
||||
fromWarehouseId: current.fromWarehouseId || firstOption.warehouseId,
|
||||
fromLocationId: current.fromLocationId || firstOption.locationId,
|
||||
toWarehouseId: current.toWarehouseId || firstOption.warehouseId,
|
||||
toLocationId: current.toLocationId || firstOption.locationId,
|
||||
}));
|
||||
})
|
||||
.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 }));
|
||||
}
|
||||
|
||||
function updateTransferField<Key extends keyof InventoryTransferInput>(key: Key, value: InventoryTransferInput[Key]) {
|
||||
setTransferForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function submitTransaction() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingTransaction(true);
|
||||
setTransactionStatus("Saving stock transaction...");
|
||||
|
||||
try {
|
||||
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
|
||||
setItem(nextItem);
|
||||
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,
|
||||
warehouseId: current.warehouseId,
|
||||
locationId: current.locationId,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save stock transaction.";
|
||||
setTransactionStatus(message);
|
||||
} finally {
|
||||
setIsSavingTransaction(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTransfer() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingTransfer(true);
|
||||
setTransferStatus("Saving transfer...");
|
||||
|
||||
try {
|
||||
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
|
||||
setItem(nextItem);
|
||||
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);
|
||||
} finally {
|
||||
setIsSavingTransfer(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReservation() {
|
||||
if (!token || !itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingReservation(true);
|
||||
setReservationStatus("Saving reservation...");
|
||||
|
||||
try {
|
||||
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
|
||||
setItem(nextItem);
|
||||
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.";
|
||||
setReservationStatus(message);
|
||||
} finally {
|
||||
setIsSavingReservation(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
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 DETAIL</p>
|
||||
<h3 className="module-title">{item.sku}</h3>
|
||||
<p className="mt-1 text-sm text-text">{item.name}</p>
|
||||
<div className="mt-2.5 flex flex-wrap gap-2">
|
||||
<InventoryTypeBadge type={item.type} />
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</div>
|
||||
<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">
|
||||
Back to items
|
||||
</Link>
|
||||
{canManage ? (
|
||||
<Link to={`/inventory/items/${item.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
Edit item
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="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="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="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="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="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="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="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>
|
||||
<dd className="mt-1 text-sm leading-6 text-text">{item.description || "No description provided."}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unit of measure</dt>
|
||||
<dd className="mt-2 text-sm text-text">{item.unitOfMeasure}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default cost</dt>
|
||||
<dd className="mt-2 text-sm text-text">{item.defaultCost == null ? "Not set" : `$${item.defaultCost.toFixed(2)}`}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
{item.isSellable ? "Sellable" : "Not sellable"} / {item.isPurchasable ? "Purchasable" : "Not purchasable"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<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-3 text-sm text-muted">No stock or reservation balances posted yet.</p>
|
||||
) : (
|
||||
<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">
|
||||
<div className="font-semibold text-text">
|
||||
{balance.warehouseCode} / {balance.locationCode}
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
{balance.warehouseName} / {balance.locationName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-text">{balance.quantityOnHand} on hand</div>
|
||||
<div className="text-xs text-muted">{balance.quantityReserved} reserved / {balance.quantityAvailable} available</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
{canManage ? (
|
||||
<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>
|
||||
<select value={transactionForm.transactionType} onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{inventoryTransactionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||
<input type="number" min={1} step={1} value={transactionForm.quantity} onChange={(event) => updateTransactionField("quantity", 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>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
|
||||
<select value={transactionForm.locationId} onChange={(event) => {
|
||||
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
|
||||
updateTransactionField("locationId", event.target.value);
|
||||
if (nextLocation) {
|
||||
updateTransactionField("warehouseId", nextLocation.warehouseId);
|
||||
}
|
||||
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{locationOptions.map((option) => (
|
||||
<option key={option.locationId} value={option.locationId}>
|
||||
{option.warehouseCode} / {option.locationCode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Reference</span>
|
||||
<input value={transactionForm.reference} onChange={(event) => updateTransactionField("reference", event.target.value)} placeholder="PO, WO, adjustment note, etc." 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={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>
|
||||
<button type="submit" disabled={isSavingTransaction} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{isSavingTransaction ? "Posting..." : "Post transaction"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">RECENT MOVEMENTS</p>
|
||||
{item.recentTransactions.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 stock transactions recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.recentTransactions.map((transaction) => (
|
||||
<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">
|
||||
<InventoryTransactionTypeBadge type={transaction.transactionType} />
|
||||
<span className={`text-sm font-semibold ${transaction.signedQuantity >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
|
||||
{transaction.signedQuantity >= 0 ? "+" : ""}
|
||||
{transaction.signedQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-text">
|
||||
{transaction.warehouseCode} / {transaction.locationCode}
|
||||
</div>
|
||||
{transaction.reference ? <div className="mt-2 text-xs text-muted">Ref: {transaction.reference}</div> : null}
|
||||
{transaction.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null}
|
||||
</div>
|
||||
<div className="text-sm text-muted lg:text-right">
|
||||
<div>{new Date(transaction.createdAt).toLocaleString()}</div>
|
||||
<div className="mt-1">{transaction.createdByName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{canManage ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<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" />
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">From</span>
|
||||
<select value={transferForm.fromLocationId} onChange={(event) => {
|
||||
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
||||
updateTransferField("fromLocationId", event.target.value);
|
||||
if (option) {
|
||||
updateTransferField("fromWarehouseId", option.warehouseId);
|
||||
}
|
||||
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{locationOptions.map((option) => (
|
||||
<option key={`from-${option.locationId}`} value={option.locationId}>
|
||||
{option.warehouseCode} / {option.locationCode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">To</span>
|
||||
<select value={transferForm.toLocationId} onChange={(event) => {
|
||||
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
||||
updateTransferField("toLocationId", event.target.value);
|
||||
if (option) {
|
||||
updateTransferField("toWarehouseId", option.warehouseId);
|
||||
}
|
||||
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{locationOptions.map((option) => (
|
||||
<option key={`to-${option.locationId}`} value={option.locationId}>
|
||||
{option.warehouseCode} / {option.locationCode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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-[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>
|
||||
<button type="submit" disabled={isSavingTransfer} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{isSavingTransfer ? "Posting transfer..." : "Post transfer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<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" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
||||
<select value={reservationForm.locationId ?? ""} onChange={(event) => {
|
||||
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
||||
setReservationForm((current) => ({
|
||||
...current,
|
||||
locationId: event.target.value || null,
|
||||
warehouseId: option ? option.warehouseId : 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="">Global / not location-specific</option>
|
||||
{locationOptions.map((option) => (
|
||||
<option key={option.locationId} value={option.locationId}>
|
||||
{option.warehouseCode} / {option.locationCode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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-[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>
|
||||
<button type="submit" disabled={isSavingReservation} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{isSavingReservation ? "Saving reservation..." : "Create reservation"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">RESERVATIONS</p>
|
||||
{item.reservations.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 reservations recorded.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.reservations.map((reservation) => (
|
||||
<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>
|
||||
<div className="mt-1 text-xs text-muted">{reservation.sourceLabel ?? reservation.sourceType}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted">{reservation.status}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">
|
||||
{reservation.warehouseCode && reservation.locationCode ? `${reservation.warehouseCode} / ${reservation.locationCode}` : "Not location-specific"}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-text">{reservation.notes || "No notes recorded."}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">TRANSFERS</p>
|
||||
{item.transfers.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 transfers recorded.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.transfers.map((transfer) => (
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">
|
||||
{transfer.fromWarehouseCode} / {transfer.fromLocationCode} to {transfer.toWarehouseCode} / {transfer.toLocationCode}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-text">{transfer.notes || "No notes recorded."}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
1022
client/src/modules/inventory/InventoryFormPage.tsx
Normal file
1022
client/src/modules/inventory/InventoryFormPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InventoryListPage } from "./InventoryListPage";
|
||||
|
||||
export function InventoryItemsPage() {
|
||||
return <InventoryListPage />;
|
||||
}
|
||||
150
client/src/modules/inventory/InventoryListPage.tsx
Normal file
150
client/src/modules/inventory/InventoryListPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { InventoryItemStatus, InventoryItemSummaryDto, InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { inventoryStatusFilters, inventoryTypeFilters } from "./config";
|
||||
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
||||
import { InventoryTypeBadge } from "./InventoryTypeBadge";
|
||||
|
||||
export function InventoryListPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [items, setItems] = useState<InventoryItemSummaryDto[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | InventoryItemStatus>("ALL");
|
||||
const [typeFilter, setTypeFilter] = useState<"ALL" | InventoryItemType>("ALL");
|
||||
const [status, setStatus] = useState("Loading inventory items...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getInventoryItems(token, {
|
||||
q: searchTerm.trim() || undefined,
|
||||
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||
type: typeFilter === "ALL" ? undefined : typeFilter,
|
||||
})
|
||||
.then((nextItems) => {
|
||||
setItems(nextItems);
|
||||
setStatus(`${nextItems.length} item(s) matched the current filters.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load inventory items.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [searchTerm, statusFilter, token, typeFilter]);
|
||||
|
||||
return (
|
||||
<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">INVENTORY</p>
|
||||
<h3 className="module-title">ITEM MASTER</h3>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<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-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
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Search by SKU, item name, or description"
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "ALL" | InventoryItemStatus)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{inventoryStatusFilters.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">Type</span>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(event) => setTypeFilter(event.target.value as "ALL" | InventoryItemType)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{inventoryTypeFilters.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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-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-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">Item</th>
|
||||
<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">Qty</th>
|
||||
<th className="px-2 py-2">BOM</th>
|
||||
<th className="px-2 py-2">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="transition hover:bg-page/70">
|
||||
<td className="px-2 py-2">
|
||||
<Link to={`/inventory/items/${item.id}`} className="font-semibold text-text hover:text-brand">
|
||||
{item.sku}
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-muted">{item.name}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<InventoryTypeBadge type={item.type} />
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{item.unitOfMeasure}</td>
|
||||
<td className="px-2 py-2 text-xs text-muted">
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal file
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { InventoryItemStatus } from "@mrp/shared/dist/inventory/types.js";
|
||||
|
||||
import { inventoryStatusPalette } from "./config";
|
||||
|
||||
const labels: Record<InventoryItemStatus, string> = {
|
||||
DRAFT: "Draft",
|
||||
ACTIVE: "Active",
|
||||
OBSOLETE: "Obsolete",
|
||||
};
|
||||
|
||||
export function InventoryStatusBadge({ status }: { status: InventoryItemStatus }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryStatusPalette[status]}`}>
|
||||
{labels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { InventoryTransactionType } from "@mrp/shared/dist/inventory/types.js";
|
||||
|
||||
import { inventoryTransactionOptions, inventoryTransactionPalette } from "./config";
|
||||
|
||||
export function InventoryTransactionTypeBadge({ type }: { type: InventoryTransactionType }) {
|
||||
const label = inventoryTransactionOptions.find((option) => option.value === type)?.label ?? type;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${inventoryTransactionPalette[type]}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal file
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
|
||||
|
||||
import { inventoryTypePalette } from "./config";
|
||||
|
||||
const labels: Record<InventoryItemType, string> = {
|
||||
PURCHASED: "Purchased",
|
||||
MANUFACTURED: "Manufactured",
|
||||
ASSEMBLY: "Assembly",
|
||||
SERVICE: "Service",
|
||||
};
|
||||
|
||||
export function InventoryTypeBadge({ type }: { type: InventoryItemType }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryTypePalette[type]}`}>
|
||||
{labels[type]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
90
client/src/modules/inventory/WarehouseDetailPage.tsx
Normal file
90
client/src/modules/inventory/WarehouseDetailPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { WarehouseDetailDto, WarehouseLocationDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
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";
|
||||
|
||||
export function WarehouseDetailPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { warehouseId } = useParams();
|
||||
const [warehouse, setWarehouse] = useState<WarehouseDetailDto | null>(null);
|
||||
const [status, setStatus] = useState("Loading warehouse...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !warehouseId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getWarehouse(token, warehouseId)
|
||||
.then((nextWarehouse) => {
|
||||
setWarehouse(nextWarehouse);
|
||||
setStatus("Warehouse loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load warehouse.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [token, warehouseId]);
|
||||
|
||||
if (!warehouse) {
|
||||
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||
}
|
||||
|
||||
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">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">
|
||||
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">
|
||||
Edit warehouse
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<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-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="surface-panel">
|
||||
<p className="section-kicker">LOCATIONS</p>
|
||||
{warehouse.locations.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 stock locations have been defined for this warehouse yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-2 xl:grid-cols-2">
|
||||
{warehouse.locations.map((location: WarehouseLocationDto) => (
|
||||
<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 text-muted">{location.notes || "No notes."}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
191
client/src/modules/inventory/WarehouseFormPage.tsx
Normal file
191
client/src/modules/inventory/WarehouseFormPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { WarehouseInput, WarehouseLocationInput } from "@mrp/shared/dist/inventory/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 { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config";
|
||||
|
||||
export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const navigate = useNavigate();
|
||||
const { warehouseId } = useParams();
|
||||
const { token } = useAuth();
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getWarehouse(token, warehouseId)
|
||||
.then((warehouse) => {
|
||||
setForm({
|
||||
code: warehouse.code,
|
||||
name: warehouse.name,
|
||||
notes: warehouse.notes,
|
||||
locations: warehouse.locations.map((location: WarehouseLocationInput) => ({
|
||||
code: location.code,
|
||||
name: location.name,
|
||||
notes: location.notes,
|
||||
})),
|
||||
});
|
||||
setStatus("Warehouse loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load warehouse.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [mode, token, warehouseId]);
|
||||
|
||||
function updateField<Key extends keyof WarehouseInput>(key: Key, value: WarehouseInput[Key]) {
|
||||
setForm((current: WarehouseInput) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateLocation(index: number, nextLocation: WarehouseLocationInput) {
|
||||
setForm((current: WarehouseInput) => ({
|
||||
...current,
|
||||
locations: current.locations.map((location: WarehouseLocationInput, locationIndex: number) =>
|
||||
locationIndex === index ? nextLocation : location
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function addLocation() {
|
||||
setForm((current: WarehouseInput) => ({
|
||||
...current,
|
||||
locations: [...current.locations, emptyWarehouseLocationInput],
|
||||
}));
|
||||
}
|
||||
|
||||
function removeLocation(index: number) {
|
||||
setForm((current: WarehouseInput) => ({
|
||||
...current,
|
||||
locations: current.locations.filter((_location: WarehouseLocationInput, locationIndex: number) => locationIndex !== index),
|
||||
}));
|
||||
}
|
||||
|
||||
const pendingLocationRemoval = pendingLocationRemovalIndex != null ? form.locations[pendingLocationRemovalIndex] : null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving warehouse...");
|
||||
|
||||
try {
|
||||
const saved =
|
||||
mode === "create" ? await api.createWarehouse(token, form) : await api.updateWarehouse(token, warehouseId ?? "", form);
|
||||
navigate(`/inventory/warehouses/${saved.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save warehouse.";
|
||||
setStatus(message);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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="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}`}
|
||||
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="surface-panel space-y-3">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<label className="block">
|
||||
<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-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-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="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<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-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-3 space-y-3">
|
||||
{form.locations.map((location: WarehouseLocationInput, index: number) => (
|
||||
<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>
|
||||
<input value={location.code} onChange={(event) => updateLocation(index, { ...location, code: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
|
||||
<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={() => 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-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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
82
client/src/modules/inventory/WarehousesPage.tsx
Normal file
82
client/src/modules/inventory/WarehousesPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { WarehouseSummaryDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
|
||||
export function WarehousesPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [warehouses, setWarehouses] = useState<WarehouseSummaryDto[]>([]);
|
||||
const [status, setStatus] = useState("Loading warehouses...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getWarehouses(token)
|
||||
.then((nextWarehouses) => {
|
||||
setWarehouses(nextWarehouses);
|
||||
setStatus(`${nextWarehouses.length} warehouse(s) available.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load warehouses.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<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">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">
|
||||
New warehouse
|
||||
</Link>
|
||||
) : null}
|
||||
</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-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-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">Code</th>
|
||||
<th className="px-2 py-2">Name</th>
|
||||
<th className="px-2 py-2">Locations</th>
|
||||
<th className="px-2 py-2">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{warehouses.map((warehouse) => (
|
||||
<tr key={warehouse.id} className="transition hover:bg-page/70">
|
||||
<td className="px-2 py-2 font-semibold text-text">
|
||||
<Link to={`/inventory/warehouses/${warehouse.id}`} className="hover:text-brand">
|
||||
{warehouse.code}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{warehouse.name}</td>
|
||||
<td className="px-2 py-2 text-muted">{warehouse.locationCount}</td>
|
||||
<td className="px-2 py-2 text-muted">{new Date(warehouse.updatedAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
133
client/src/modules/inventory/config.ts
Normal file
133
client/src/modules/inventory/config.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
inventoryItemStatuses,
|
||||
inventoryItemTypes,
|
||||
inventoryTransactionTypes,
|
||||
inventoryUnitsOfMeasure,
|
||||
type InventoryBomLineInput,
|
||||
type InventoryItemInput,
|
||||
type InventoryItemOperationInput,
|
||||
type WarehouseInput,
|
||||
type WarehouseLocationInput,
|
||||
type InventoryItemStatus,
|
||||
type InventoryItemType,
|
||||
type InventoryTransactionInput,
|
||||
type InventoryTransactionType,
|
||||
type InventoryUnitOfMeasure,
|
||||
} from "@mrp/shared/dist/inventory/types.js";
|
||||
|
||||
export const emptyInventoryBomLineInput: InventoryBomLineInput = {
|
||||
componentItemId: "",
|
||||
quantity: 1,
|
||||
unitOfMeasure: "EA",
|
||||
notes: "",
|
||||
position: 10,
|
||||
};
|
||||
|
||||
export const emptyInventoryOperationInput: InventoryItemOperationInput = {
|
||||
stationId: "",
|
||||
setupMinutes: 0,
|
||||
runMinutesPerUnit: 0,
|
||||
moveMinutes: 0,
|
||||
position: 10,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export const emptyInventoryItemInput: InventoryItemInput = {
|
||||
sku: "",
|
||||
skuBuilder: null,
|
||||
name: "",
|
||||
description: "",
|
||||
type: "PURCHASED",
|
||||
status: "ACTIVE",
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: true,
|
||||
isPurchasable: true,
|
||||
preferredVendorId: null,
|
||||
defaultCost: null,
|
||||
defaultPrice: null,
|
||||
notes: "",
|
||||
bomLines: [],
|
||||
operations: [],
|
||||
};
|
||||
|
||||
export const emptyInventoryTransactionInput: InventoryTransactionInput = {
|
||||
transactionType: "RECEIPT",
|
||||
quantity: 1,
|
||||
warehouseId: "",
|
||||
locationId: "",
|
||||
reference: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export const emptyWarehouseLocationInput: WarehouseLocationInput = {
|
||||
code: "",
|
||||
name: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export const emptyWarehouseInput: WarehouseInput = {
|
||||
code: "",
|
||||
name: "",
|
||||
notes: "",
|
||||
locations: [],
|
||||
};
|
||||
|
||||
export const inventoryTypeOptions: Array<{ value: InventoryItemType; label: string }> = [
|
||||
{ value: "PURCHASED", label: "Purchased" },
|
||||
{ value: "MANUFACTURED", label: "Manufactured" },
|
||||
{ value: "ASSEMBLY", label: "Assembly" },
|
||||
{ value: "SERVICE", label: "Service" },
|
||||
];
|
||||
|
||||
export const inventoryStatusOptions: Array<{ value: InventoryItemStatus; label: string }> = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "OBSOLETE", label: "Obsolete" },
|
||||
];
|
||||
|
||||
export const inventoryStatusFilters: Array<{ value: "ALL" | InventoryItemStatus; label: string }> = [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...inventoryStatusOptions,
|
||||
];
|
||||
|
||||
export const inventoryTypeFilters: Array<{ value: "ALL" | InventoryItemType; label: string }> = [
|
||||
{ value: "ALL", label: "All item types" },
|
||||
...inventoryTypeOptions,
|
||||
];
|
||||
|
||||
export const inventoryUnitOptions: Array<{ value: InventoryUnitOfMeasure; label: string }> = inventoryUnitsOfMeasure.map((unit) => ({
|
||||
value: unit,
|
||||
label: unit,
|
||||
}));
|
||||
|
||||
export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
|
||||
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
OBSOLETE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
|
||||
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",
|
||||
MANUFACTURED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
ASSEMBLY: "border border-brand/30 bg-brand/10 text-brand",
|
||||
SERVICE: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||
};
|
||||
|
||||
export const inventoryTransactionOptions: Array<{ value: InventoryTransactionType; label: string }> = [
|
||||
{ value: "RECEIPT", label: "Receipt" },
|
||||
{ value: "ISSUE", label: "Issue" },
|
||||
{ value: "ADJUSTMENT_IN", label: "Adjustment In" },
|
||||
{ value: "ADJUSTMENT_OUT", label: "Adjustment Out" },
|
||||
];
|
||||
|
||||
export const inventoryTransactionPalette: Record<InventoryTransactionType, string> = {
|
||||
RECEIPT: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
ISSUE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
ADJUSTMENT_IN: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
ADJUSTMENT_OUT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
};
|
||||
|
||||
export { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure };
|
||||
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" />;
|
||||
}
|
||||
@@ -31,15 +31,15 @@ export function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="grid w-full max-w-5xl overflow-hidden rounded-[32px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="bg-brand px-8 py-12 text-white md:px-12">
|
||||
<section className="bg-brand px-6 py-10 text-white md:px-10">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-white/75">MRP Codex</p>
|
||||
<h1 className="mt-6 text-4xl font-extrabold">A streamlined manufacturing operating system.</h1>
|
||||
<p className="mt-5 max-w-xl text-base text-white/82">
|
||||
<p className="mt-4 max-w-xl text-sm leading-6 text-white/82">
|
||||
This foundation release establishes authentication, company settings, brand theming, file persistence, and planning scaffolding.
|
||||
</p>
|
||||
</section>
|
||||
<section className="px-8 py-12 md:px-12">
|
||||
<h2 className="text-2xl font-bold text-text">Sign in</h2>
|
||||
<section className="px-6 py-10 md:px-10">
|
||||
<h2 className="text-lg font-bold text-text">Sign in</h2>
|
||||
<p className="mt-2 text-sm text-muted">Use the seeded admin account to access the initial platform shell.</p>
|
||||
<form className="mt-8 space-y-5" onSubmit={handleSubmit}>
|
||||
<label className="block">
|
||||
@@ -47,7 +47,7 @@ export function LoginPage() {
|
||||
<input
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 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">
|
||||
@@ -56,14 +56,14 @@ export function LoginPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 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>
|
||||
{error ? <div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200 dark:text-red-200">{error}</div> : null}
|
||||
{error ? <div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-2 py-2 text-sm text-red-200 dark:text-red-200">{error}</div> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-2xl bg-text px-4 py-3 text-sm font-semibold text-page transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="w-full rounded-2xl bg-text px-2 py-2 text-sm font-semibold text-page transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Enter workspace"}
|
||||
</button>
|
||||
|
||||
202
client/src/modules/manufacturing/ManufacturingPage.tsx
Normal file
202
client/src/modules/manufacturing/ManufacturingPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { permissions, type ManufacturingStationInput, type ManufacturingStationDto } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { WorkOrderListPage } from "./WorkOrderListPage";
|
||||
|
||||
const emptyStationInput: ManufacturingStationInput = {
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
queueDays: 0,
|
||||
dailyCapacityMinutes: 480,
|
||||
parallelCapacity: 1,
|
||||
workingDays: [1, 2, 3, 4, 5],
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus(editingStationId ? "Updating station..." : "Saving station...");
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]">
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">MANUFACTURING STATIONS</p>
|
||||
<h3 className="module-title">SCHEDULING ANCHORS</h3>
|
||||
{stations.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 stations defined yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{stations.map((station) => (
|
||||
<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>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
{canManage ? (
|
||||
<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-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-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-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-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 }))} />
|
||||
<span className="text-sm font-semibold text-text">Active station</span>
|
||||
</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>
|
||||
<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>
|
||||
) : null}
|
||||
</section>
|
||||
<WorkOrderListPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
844
client/src/modules/manufacturing/WorkOrderDetailPage.tsx
Normal file
844
client/src/modules/manufacturing/WorkOrderDetailPage.tsx
Normal file
@@ -0,0 +1,844 @@
|
||||
import { permissions } 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";
|
||||
|
||||
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";
|
||||
|
||||
export function WorkOrderDetailPage() {
|
||||
const { token, user } = useAuth();
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !workOrderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getWorkOrder(token, workOrderId)
|
||||
.then((nextWorkOrder) => {
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setIssueForm({
|
||||
...emptyMaterialIssueInput,
|
||||
warehouseId: nextWorkOrder.warehouseId,
|
||||
locationId: nextWorkOrder.locationId,
|
||||
});
|
||||
setCompletionForm({
|
||||
...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) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
|
||||
setStatus(message);
|
||||
});
|
||||
|
||||
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
|
||||
api.getManufacturingUserOptions(token).then(setOperatorOptions).catch(() => setOperatorOptions([]));
|
||||
}, [token, workOrderId]);
|
||||
|
||||
const filteredLocationOptions = useMemo(
|
||||
() => locationOptions.filter((option) => option.warehouseId === issueForm.warehouseId),
|
||||
[issueForm.warehouseId, locationOptions]
|
||||
);
|
||||
|
||||
async function applyStatusChange(nextStatus: WorkOrderStatus) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingStatus(true);
|
||||
setStatus("Updating work-order status...");
|
||||
try {
|
||||
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, {
|
||||
status: nextStatus,
|
||||
reason: nextStatus === "ON_HOLD" ? holdReasonDraft : null,
|
||||
});
|
||||
setWorkOrder(nextWorkOrder);
|
||||
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);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitIssue() {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPostingIssue(true);
|
||||
setStatus("Posting material issue...");
|
||||
try {
|
||||
const nextWorkOrder = await api.issueWorkOrderMaterial(token, workOrder.id, issueForm);
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setIssueForm({
|
||||
...emptyMaterialIssueInput,
|
||||
warehouseId: nextWorkOrder.warehouseId,
|
||||
locationId: nextWorkOrder.locationId,
|
||||
});
|
||||
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);
|
||||
} finally {
|
||||
setIsPostingIssue(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCompletion() {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPostingCompletion(true);
|
||||
setStatus("Posting completion...");
|
||||
try {
|
||||
const nextWorkOrder = await api.recordWorkOrderCompletion(token, workOrder.id, completionForm);
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setCompletionForm({
|
||||
...emptyCompletionInput,
|
||||
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||
});
|
||||
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);
|
||||
} finally {
|
||||
setIsPostingCompletion(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
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">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-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="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{workOrderStatusOptions.map((option) => (
|
||||
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || workOrder.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-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="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="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="surface-panel">
|
||||
<p className="section-kicker">OPERATION PLAN</p>
|
||||
{workOrder.operations.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">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
|
||||
) : (
|
||||
<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">Planned / Actual</th>
|
||||
{canManage ? <th className="px-3 py-3">Execution Controls</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
{workOrder.operations.map((operation) => (
|
||||
<tr key={operation.id} className="bg-surface/70">
|
||||
<td className="px-3 py-3 text-text">{operation.sequence}</td>
|
||||
<td className="px-3 py-3">
|
||||
<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-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>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{canManage ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<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-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) => (
|
||||
<option key={requirement.componentItemId} value={requirement.componentItemId}>{requirement.componentSku} - {requirement.componentName}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block">
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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) => (
|
||||
<option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<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-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="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-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-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">
|
||||
{isPostingCompletion ? "Posting completion..." : "Post completion"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="surface-panel">
|
||||
<p className="section-kicker">MATERIAL REQUIREMENTS</p>
|
||||
{workOrder.materialRequirements.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">This build item does not currently have BOM material requirements.</div>
|
||||
) : (
|
||||
<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">Component</th>
|
||||
<th className="px-3 py-3">Per</th>
|
||||
<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">
|
||||
{workOrder.materialRequirements.map((requirement) => (
|
||||
<tr key={requirement.componentItemId} className="bg-surface/70">
|
||||
<td className="px-3 py-3"><div className="font-semibold text-text">{requirement.componentSku}</div><div className="mt-1 text-xs text-muted">{requirement.componentName}</div></td>
|
||||
<td className="px-3 py-3 text-text">{requirement.quantityPer} {requirement.unitOfMeasure}</td>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">ISSUE HISTORY</p>
|
||||
{workOrder.materialIssues.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 material issues have been posted yet.</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{workOrder.materialIssues.map((issue) => (
|
||||
<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>
|
||||
<div className="mt-1 text-xs text-muted">{issue.warehouseCode} / {issue.locationCode} · {issue.createdByName}</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text">{issue.quantity}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">{new Date(issue.createdAt).toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-text">{issue.notes || "No notes recorded."}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">COMPLETION HISTORY</p>
|
||||
{workOrder.completions.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 production completions have been posted yet.</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{workOrder.completions.map((completion) => (
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">{new Date(completion.createdAt).toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-text">{completion.notes || "No notes recorded."}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
<FileAttachmentsPanel
|
||||
ownerType="WORK_ORDER"
|
||||
ownerId={workOrder.id}
|
||||
eyebrow="Manufacturing Documents"
|
||||
title="Work-order files"
|
||||
description="Store travelers, build instructions, inspection records, and support documents directly on the work order."
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
310
client/src/modules/manufacturing/WorkOrderFormPage.tsx
Normal file
310
client/src/modules/manufacturing/WorkOrderFormPage.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import type {
|
||||
ManufacturingItemOptionDto,
|
||||
ManufacturingProjectOptionDto,
|
||||
WorkOrderInput,
|
||||
} from "@mrp/shared";
|
||||
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyWorkOrderInput, workOrderStatusOptions } from "./config";
|
||||
|
||||
export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const { token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
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[]>([]);
|
||||
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||
const [itemSearchTerm, setItemSearchTerm] = useState("");
|
||||
const [projectSearchTerm, setProjectSearchTerm] = useState("");
|
||||
const [itemPickerOpen, setItemPickerOpen] = useState(false);
|
||||
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
||||
const [status, setStatus] = useState(mode === "create" ? "Create a new work order." : "Loading work order...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
const seededProject = options.find((option) => option.id === seededProjectId);
|
||||
if (seededProject) {
|
||||
setForm((current) => ({ ...current, projectId: seededProject.id }));
|
||||
setProjectSearchTerm(`${seededProject.projectNumber} - ${seededProject.name}`);
|
||||
}
|
||||
}
|
||||
}).catch(() => setProjectOptions([]));
|
||||
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
|
||||
}, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || mode !== "edit" || !workOrderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getWorkOrder(token, workOrderId)
|
||||
.then((workOrder) => {
|
||||
setForm({
|
||||
itemId: workOrder.itemId,
|
||||
projectId: workOrder.projectId,
|
||||
salesOrderId: workOrder.salesOrderId,
|
||||
salesOrderLineId: workOrder.salesOrderLineId,
|
||||
status: workOrder.status,
|
||||
quantity: workOrder.quantity,
|
||||
warehouseId: workOrder.warehouseId,
|
||||
locationId: workOrder.locationId,
|
||||
dueDate: workOrder.dueDate,
|
||||
notes: workOrder.notes,
|
||||
});
|
||||
setItemSearchTerm(`${workOrder.itemSku} - ${workOrder.itemName}`);
|
||||
setProjectSearchTerm(workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "");
|
||||
setStatus("Work order loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [mode, token, workOrderId]);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => [...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()],
|
||||
[locationOptions]
|
||||
);
|
||||
|
||||
const filteredLocationOptions = useMemo(
|
||||
() => locationOptions.filter((option) => option.warehouseId === form.warehouseId),
|
||||
[form.warehouseId, locationOptions]
|
||||
);
|
||||
|
||||
function updateField<Key extends keyof WorkOrderInput>(key: Key, value: WorkOrderInput[Key]) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(key === "warehouseId" ? { locationId: "" } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving work order...");
|
||||
try {
|
||||
const saved = mode === "create" ? await api.createWorkOrder(token, form) : await api.updateWorkOrder(token, workOrderId ?? "", form);
|
||||
navigate(`/manufacturing/work-orders/${saved.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save work order.";
|
||||
setStatus(message);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
navigate(mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="section-kicker">MANUFACTURING EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "NEW WORK ORDER" : "EDIT WORK ORDER"}</h3>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={itemSearchTerm}
|
||||
onChange={(event) => {
|
||||
setItemSearchTerm(event.target.value);
|
||||
updateField("itemId", "");
|
||||
setItemPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setItemPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setItemPickerOpen(false);
|
||||
const selected = itemOptions.find((option) => option.id === form.itemId);
|
||||
if (selected) {
|
||||
setItemSearchTerm(`${selected.sku} - ${selected.name}`);
|
||||
}
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="Search manufactured item"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{itemPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
{itemOptions
|
||||
.filter((option) => {
|
||||
const query = itemSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return option.sku.toLowerCase().includes(query) || option.name.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((option) => (
|
||||
<button key={option.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("itemId", option.id);
|
||||
setItemSearchTerm(`${option.sku} - ${option.name}`);
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Project</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={projectSearchTerm}
|
||||
onChange={(event) => {
|
||||
setProjectSearchTerm(event.target.value);
|
||||
updateField("projectId", null);
|
||||
setProjectPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setProjectPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setProjectPickerOpen(false);
|
||||
const selected = projectOptions.find((option) => option.id === form.projectId);
|
||||
if (selected) {
|
||||
setProjectSearchTerm(`${selected.projectNumber} - ${selected.name}`);
|
||||
}
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="Search linked project (optional)"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{projectPickerOpen ? (
|
||||
<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("projectId", null);
|
||||
setProjectSearchTerm("");
|
||||
setProjectPickerOpen(false);
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||
<div className="font-semibold text-text">No linked project</div>
|
||||
</button>
|
||||
{projectOptions
|
||||
.filter((option) => {
|
||||
const query = projectSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return option.projectNumber.toLowerCase().includes(query) || option.name.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((option) => (
|
||||
<button key={option.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("projectId", option.id);
|
||||
setProjectSearchTerm(`${option.projectNumber} - ${option.name}`);
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select value={form.status} onChange={(event) => updateField("status", event.target.value as WorkOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{workOrderStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||
<input type="number" min={1} step={1} value={form.quantity} onChange={(event) => updateField("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">Warehouse</span>
|
||||
<select value={form.warehouseId} onChange={(event) => updateField("warehouseId", 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 warehouse</option>
|
||||
{warehouseOptions.map((option) => <option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
||||
<select value={form.locationId} onChange={(event) => updateField("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) => <option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block max-w-sm">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
|
||||
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("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>
|
||||
<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-[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-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"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
109
client/src/modules/manufacturing/WorkOrderListPage.tsx
Normal file
109
client/src/modules/manufacturing/WorkOrderListPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { WorkOrderStatus, WorkOrderSummaryDto } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { workOrderStatusFilters } from "./config";
|
||||
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
|
||||
|
||||
export function WorkOrderListPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | WorkOrderStatus>("ALL");
|
||||
const [status, setStatus] = useState("Loading work orders...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Loading work orders...");
|
||||
api.getWorkOrders(token, { q: query || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
|
||||
.then((nextWorkOrders) => {
|
||||
setWorkOrders(nextWorkOrders);
|
||||
setStatus(nextWorkOrders.length === 0 ? "No work orders matched the current filters." : `${nextWorkOrders.length} work order(s) loaded.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load work orders.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [query, statusFilter, token]);
|
||||
|
||||
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">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">
|
||||
New work order
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | WorkOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{workOrderStatusFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</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-[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-[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">
|
||||
<th className="px-3 py-3">Work Order</th>
|
||||
<th className="px-3 py-3">Item</th>
|
||||
<th className="px-3 py-3">Project</th>
|
||||
<th className="px-3 py-3">Status</th>
|
||||
<th className="px-3 py-3">Qty</th>
|
||||
<th className="px-3 py-3">Location</th>
|
||||
<th className="px-3 py-3">Ops</th>
|
||||
<th className="px-3 py-3">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
{workOrders.map((workOrder) => (
|
||||
<tr key={workOrder.id} className="bg-surface/70 transition hover:bg-page/60">
|
||||
<td className="px-3 py-3 align-top">
|
||||
<Link to={`/manufacturing/work-orders/${workOrder.id}`} className="font-semibold text-text hover:text-brand">{workOrder.workOrderNumber}</Link>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-text">{workOrder.itemSku}</div>
|
||||
<div className="mt-1 text-xs text-muted">{workOrder.itemName}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-text">{workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "Unlinked"}</td>
|
||||
<td className="px-3 py-3 align-top"><WorkOrderStatusBadge status={workOrder.status} /></td>
|
||||
<td className="px-3 py-3 align-top text-text">{workOrder.completedQuantity} / {workOrder.quantity}</td>
|
||||
<td className="px-3 py-3 align-top text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</td>
|
||||
<td className="px-3 py-3 align-top text-text">{workOrder.operationCount} / {Math.round(workOrder.totalPlannedMinutes / 60)}h</td>
|
||||
<td className="px-3 py-3 align-top text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { WorkOrderStatus } from "@mrp/shared";
|
||||
|
||||
import { workOrderStatusPalette } from "./config";
|
||||
|
||||
export function WorkOrderStatusBadge({ status }: { status: WorkOrderStatus }) {
|
||||
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${workOrderStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
|
||||
}
|
||||
50
client/src/modules/manufacturing/config.ts
Normal file
50
client/src/modules/manufacturing/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { WorkOrderCompletionInput, WorkOrderInput, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
|
||||
|
||||
export const workOrderStatusOptions: Array<{ value: WorkOrderStatus; label: string }> = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "RELEASED", label: "Released" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "ON_HOLD", label: "On Hold" },
|
||||
{ value: "COMPLETE", label: "Complete" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
export const workOrderStatusFilters: Array<{ value: "ALL" | WorkOrderStatus; label: string }> = [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...workOrderStatusOptions,
|
||||
];
|
||||
|
||||
export const workOrderStatusPalette: Record<WorkOrderStatus, string> = {
|
||||
DRAFT: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
RELEASED: "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",
|
||||
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
COMPLETE: "border border-brand/30 bg-brand/10 text-brand",
|
||||
CANCELLED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
|
||||
export const emptyWorkOrderInput: WorkOrderInput = {
|
||||
itemId: "",
|
||||
projectId: null,
|
||||
salesOrderId: null,
|
||||
salesOrderLineId: null,
|
||||
status: "DRAFT",
|
||||
quantity: 1,
|
||||
warehouseId: "",
|
||||
locationId: "",
|
||||
dueDate: null,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export const emptyMaterialIssueInput: WorkOrderMaterialIssueInput = {
|
||||
componentItemId: "",
|
||||
warehouseId: "",
|
||||
locationId: "",
|
||||
quantity: 1,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export const emptyCompletionInput: WorkOrderCompletionInput = {
|
||||
quantity: 1,
|
||||
notes: "",
|
||||
};
|
||||
391
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
391
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { ProjectMilestoneStatus, WorkOrderSummaryDto } from "@mrp/shared";
|
||||
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
||||
import type { SalesOrderPlanningDto } 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 { 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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getProject(token, projectId)
|
||||
.then(async (nextProject) => {
|
||||
setProject(nextProject);
|
||||
setStatus("Project loaded.");
|
||||
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);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load project.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [projectId, token]);
|
||||
|
||||
if (!project) {
|
||||
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="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">PROJECT</p>
|
||||
<h3 className="module-title">{project.projectNumber}</h3>
|
||||
<p className="mt-1 text-sm text-text">{project.name}</p>
|
||||
<div className="mt-2.5 flex flex-wrap gap-2">
|
||||
<ProjectStatusBadge status={project.status} />
|
||||
<ProjectPriorityBadge priority={project.priority} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/projects" 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 projects</Link>
|
||||
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit project</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">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="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="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="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="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><p className="section-kicker">MILESTONES</p></div>
|
||||
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
||||
</div>
|
||||
{project.milestones.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-3 space-y-2">{project.milestones.map((milestone) => (<div key={milestone.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div>{canManage ? <div className="flex flex-wrap gap-2">{milestoneQuickActions(milestone.status).map((action) => (<button key={action.status} type="button" onClick={() => void updateMilestoneStatus(milestone.id, action.status)} disabled={updatingMilestoneId === milestone.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">{updatingMilestoneId === milestone.id ? "Saving..." : action.label}</button>))}</div> : null}</div></div>))}</div>}
|
||||
</section>
|
||||
{planning ? (
|
||||
<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-3 space-y-2">
|
||||
{project.timeline.map((entry) => (
|
||||
<div key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
|
||||
<div className="mt-1 font-semibold text-text">
|
||||
{entry.href ? <Link to={entry.href} className="hover:text-brand">{entry.title}</Link> : entry.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted">{entry.detail}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>{new Date(entry.createdAt).toLocaleString()}</div>
|
||||
<div>{entry.actorName || "System"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
667
client/src/modules/projects/ProjectFormPage.tsx
Normal file
667
client/src/modules/projects/ProjectFormPage.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
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, 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();
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const [form, setForm] = useState<ProjectInput>(() => ({ ...emptyProjectInput, ownerId: user?.id ?? null }));
|
||||
const [customerOptions, setCustomerOptions] = useState<ProjectCustomerOptionDto[]>([]);
|
||||
const [ownerOptions, setOwnerOptions] = useState<ProjectOwnerOptionDto[]>([]);
|
||||
const [quoteOptions, setQuoteOptions] = useState<ProjectDocumentOptionDto[]>([]);
|
||||
const [orderOptions, setOrderOptions] = useState<ProjectDocumentOptionDto[]>([]);
|
||||
const [shipmentOptions, setShipmentOptions] = useState<ProjectShipmentOptionDto[]>([]);
|
||||
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
|
||||
const [ownerSearchTerm, setOwnerSearchTerm] = useState("");
|
||||
const [quoteSearchTerm, setQuoteSearchTerm] = useState("");
|
||||
const [orderSearchTerm, setOrderSearchTerm] = useState("");
|
||||
const [shipmentSearchTerm, setShipmentSearchTerm] = useState("");
|
||||
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
|
||||
const [ownerPickerOpen, setOwnerPickerOpen] = useState(false);
|
||||
const [quotePickerOpen, setQuotePickerOpen] = useState(false);
|
||||
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
|
||||
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
|
||||
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
|
||||
const [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) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getProjectCustomerOptions(token).then(setCustomerOptions).catch(() => setCustomerOptions([]));
|
||||
api.getProjectOwnerOptions(token).then(setOwnerOptions).catch(() => setOwnerOptions([]));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !form.customerId) {
|
||||
setQuoteOptions([]);
|
||||
setOrderOptions([]);
|
||||
setShipmentOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
api.getProjectQuoteOptions(token, form.customerId).then(setQuoteOptions).catch(() => setQuoteOptions([]));
|
||||
api.getProjectOrderOptions(token, form.customerId).then(setOrderOptions).catch(() => setOrderOptions([]));
|
||||
api.getProjectShipmentOptions(token, form.customerId).then(setShipmentOptions).catch(() => setShipmentOptions([]));
|
||||
}, [form.customerId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || mode !== "edit" || !projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getProject(token, projectId)
|
||||
.then((project) => {
|
||||
setForm({
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
customerId: project.customerId,
|
||||
salesQuoteId: project.salesQuoteId,
|
||||
salesOrderId: project.salesOrderId,
|
||||
shipmentId: project.shipmentId,
|
||||
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 ?? "");
|
||||
setQuoteSearchTerm(project.salesQuoteNumber ?? "");
|
||||
setOrderSearchTerm(project.salesOrderNumber ?? "");
|
||||
setShipmentSearchTerm(project.shipmentNumber ?? "");
|
||||
setStatus("Project loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load project.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [mode, projectId, token]);
|
||||
|
||||
function updateField<Key extends keyof ProjectInput>(key: Key, value: ProjectInput[Key]) {
|
||||
setForm((current: ProjectInput) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(key === "customerId"
|
||||
? {
|
||||
salesQuoteId: null,
|
||||
salesOrderId: null,
|
||||
shipmentId: null,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
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);
|
||||
const selectedQuote = quoteOptions.find((quote) => quote.id === form.salesQuoteId);
|
||||
const selectedOrder = orderOptions.find((order) => order.id === form.salesOrderId);
|
||||
const selectedShipment = shipmentOptions.find((shipment) => shipment.id === form.shipmentId);
|
||||
|
||||
setCustomerSearchTerm(selectedCustomer?.name ?? "");
|
||||
setOwnerSearchTerm(selectedOwner?.fullName ?? "");
|
||||
setQuoteSearchTerm(selectedQuote?.documentNumber ?? "");
|
||||
setOrderSearchTerm(selectedOrder?.documentNumber ?? "");
|
||||
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving project...");
|
||||
try {
|
||||
const saved = mode === "create" ? await api.createProject(token, form) : await api.updateProject(token, projectId ?? "", form);
|
||||
navigate(`/projects/${saved.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save project.";
|
||||
setStatus(message);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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="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-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>
|
||||
<input value={form.name} onChange={(event) => updateField("name", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label 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);
|
||||
setCustomerPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setCustomerPickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setCustomerPickerOpen(false);
|
||||
restoreSearchTerms();
|
||||
}, 120)}
|
||||
placeholder="Search customer"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{customerPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
{customerOptions
|
||||
.filter((customer) => {
|
||||
const query = customerSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((customer) => (
|
||||
<button key={customer.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select value={form.status} onChange={(event) => updateField("status", event.target.value as ProjectInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{projectStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Priority</span>
|
||||
<select value={form.priority} onChange={(event) => updateField("priority", event.target.value as ProjectInput["priority"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{projectPriorityOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Owner</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={ownerSearchTerm}
|
||||
onChange={(event) => {
|
||||
setOwnerSearchTerm(event.target.value);
|
||||
updateField("ownerId", null);
|
||||
setOwnerPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setOwnerPickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setOwnerPickerOpen(false);
|
||||
restoreSearchTerms();
|
||||
}, 120)}
|
||||
placeholder="Search owner"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{ownerPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("ownerId", null);
|
||||
setOwnerSearchTerm("");
|
||||
setOwnerPickerOpen(false);
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||
<div className="font-semibold text-text">Unassigned</div>
|
||||
</button>
|
||||
{ownerOptions
|
||||
.filter((owner) => {
|
||||
const query = ownerSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return owner.fullName.toLowerCase().includes(query) || owner.email.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((owner) => (
|
||||
<button key={owner.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("ownerId", owner.id);
|
||||
setOwnerSearchTerm(owner.fullName);
|
||||
setOwnerPickerOpen(false);
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||
<div className="font-semibold text-text">{owner.fullName}</div>
|
||||
<div className="mt-1 text-xs text-muted">{owner.email}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
|
||||
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("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="grid gap-3 xl:grid-cols-3">
|
||||
<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);
|
||||
setQuotePickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setQuotePickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setQuotePickerOpen(false);
|
||||
restoreSearchTerms();
|
||||
}, 120)}
|
||||
placeholder="Search quote"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{quotePickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
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>
|
||||
{quoteOptions
|
||||
.filter((quote) => {
|
||||
const query = quoteSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return quote.documentNumber.toLowerCase().includes(query) || quote.customerName.toLowerCase().includes(query) || quote.status.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((quote) => (
|
||||
<button key={quote.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("salesQuoteId", quote.id);
|
||||
setQuoteSearchTerm(quote.documentNumber);
|
||||
setQuotePickerOpen(false);
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||
<div className="font-semibold text-text">{quote.documentNumber}</div>
|
||||
<div className="mt-1 text-xs text-muted">{quote.customerName} · {quote.status}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<label 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);
|
||||
setOrderPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setOrderPickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setOrderPickerOpen(false);
|
||||
restoreSearchTerms();
|
||||
}, 120)}
|
||||
placeholder="Search sales order"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{orderPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
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>
|
||||
{orderOptions
|
||||
.filter((order) => {
|
||||
const query = orderSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return order.documentNumber.toLowerCase().includes(query) || order.customerName.toLowerCase().includes(query) || order.status.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((order) => (
|
||||
<button key={order.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("salesOrderId", order.id);
|
||||
setOrderSearchTerm(order.documentNumber);
|
||||
setOrderPickerOpen(false);
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||
<div className="font-semibold text-text">{order.documentNumber}</div>
|
||||
<div className="mt-1 text-xs text-muted">{order.customerName} · {order.status}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<label 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);
|
||||
setShipmentPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setShipmentPickerOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => {
|
||||
setShipmentPickerOpen(false);
|
||||
restoreSearchTerms();
|
||||
}, 120)}
|
||||
placeholder="Search shipment"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{shipmentPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
<button type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
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>
|
||||
{shipmentOptions
|
||||
.filter((shipment) => {
|
||||
const query = shipmentSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return shipment.shipmentNumber.toLowerCase().includes(query) || shipment.salesOrderNumber.toLowerCase().includes(query) || shipment.customerName.toLowerCase().includes(query) || shipment.status.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((shipment) => (
|
||||
<button key={shipment.id} type="button" onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("shipmentId", shipment.id);
|
||||
setShipmentSearchTerm(shipment.shipmentNumber);
|
||||
setShipmentPickerOpen(false);
|
||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
|
||||
<div className="mt-1 text-xs text-muted">{shipment.salesOrderNumber} · {shipment.status}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
</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-[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">
|
||||
{isSaving ? "Saving..." : mode === "create" ? "Create project" : "Save changes"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
122
client/src/modules/projects/ProjectListPage.tsx
Normal file
122
client/src/modules/projects/ProjectListPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { ProjectPriority, ProjectStatus, ProjectSummaryDto } from "@mrp/shared/dist/projects/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { projectPriorityFilters, projectStatusFilters } from "./config";
|
||||
import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
|
||||
import { ProjectStatusBadge } from "./ProjectStatusBadge";
|
||||
|
||||
export function ProjectListPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [projects, setProjects] = useState<ProjectSummaryDto[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | ProjectStatus>("ALL");
|
||||
const [priorityFilter, setPriorityFilter] = useState<"ALL" | ProjectPriority>("ALL");
|
||||
const [status, setStatus] = useState("Load projects, linked customer work, and program ownership.");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Loading projects...");
|
||||
api
|
||||
.getProjects(token, {
|
||||
q: query || undefined,
|
||||
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||
priority: priorityFilter === "ALL" ? undefined : priorityFilter,
|
||||
})
|
||||
.then((nextProjects) => {
|
||||
setProjects(nextProjects);
|
||||
setStatus(nextProjects.length === 0 ? "No projects matched the current filters." : `${nextProjects.length} project(s) loaded.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load projects.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [priorityFilter, query, statusFilter, token]);
|
||||
|
||||
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">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">
|
||||
New project
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | ProjectStatus)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{projectStatusFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Priority</span>
|
||||
<select value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value as "ALL" | ProjectPriority)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{projectPriorityFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</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-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-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>
|
||||
<th className="px-2 py-2">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{projects.map((project) => (
|
||||
<tr key={project.id}>
|
||||
<td className="px-2 py-2">
|
||||
<Link to={`/projects/${project.id}`} className="font-semibold text-text hover:text-brand">{project.projectNumber}</Link>
|
||||
<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>
|
||||
<td className="px-2 py-2 text-muted">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "No due date"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
7
client/src/modules/projects/ProjectPriorityBadge.tsx
Normal file
7
client/src/modules/projects/ProjectPriorityBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ProjectPriority } from "@mrp/shared/dist/projects/types.js";
|
||||
|
||||
import { projectPriorityPalette } from "./config";
|
||||
|
||||
export function ProjectPriorityBadge({ priority }: { priority: ProjectPriority }) {
|
||||
return <span className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${projectPriorityPalette[priority]}`}>{priority}</span>;
|
||||
}
|
||||
7
client/src/modules/projects/ProjectStatusBadge.tsx
Normal file
7
client/src/modules/projects/ProjectStatusBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ProjectStatus } from "@mrp/shared/dist/projects/types.js";
|
||||
|
||||
import { projectStatusPalette } from "./config";
|
||||
|
||||
export function ProjectStatusBadge({ status }: { status: ProjectStatus }) {
|
||||
return <span className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${projectStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
|
||||
}
|
||||
5
client/src/modules/projects/ProjectsPage.tsx
Normal file
5
client/src/modules/projects/ProjectsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProjectListPage } from "./ProjectListPage";
|
||||
|
||||
export function ProjectsPage() {
|
||||
return <ProjectListPage />;
|
||||
}
|
||||
69
client/src/modules/projects/config.ts
Normal file
69
client/src/modules/projects/config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "ON_HOLD", label: "On Hold" },
|
||||
{ value: "AT_RISK", label: "At Risk" },
|
||||
{ value: "COMPLETE", label: "Complete" },
|
||||
];
|
||||
|
||||
export const projectPriorityOptions: Array<{ value: ProjectPriority; label: string }> = [
|
||||
{ value: "LOW", label: "Low" },
|
||||
{ value: "MEDIUM", label: "Medium" },
|
||||
{ value: "HIGH", label: "High" },
|
||||
{ value: "CRITICAL", label: "Critical" },
|
||||
];
|
||||
|
||||
export const projectStatusFilters: Array<{ value: "ALL" | ProjectStatus; label: string }> = [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...projectStatusOptions,
|
||||
];
|
||||
|
||||
export const projectPriorityFilters: Array<{ value: "ALL" | ProjectPriority; label: string }> = [
|
||||
{ value: "ALL", label: "All priorities" },
|
||||
...projectPriorityOptions,
|
||||
];
|
||||
|
||||
export const projectStatusPalette: Record<ProjectStatus, string> = {
|
||||
PLANNED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
AT_RISK: "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 projectPriorityPalette: Record<ProjectPriority, string> = {
|
||||
LOW: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
MEDIUM: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
HIGH: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
|
||||
export const projectMilestoneStatusOptions: Array<{ value: ProjectMilestoneStatus; label: string }> = [
|
||||
{ value: "PLANNED", label: "Planned" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "BLOCKED", label: "Blocked" },
|
||||
{ value: "COMPLETE", label: "Complete" },
|
||||
];
|
||||
|
||||
export const projectMilestoneStatusPalette: Record<ProjectMilestoneStatus, string> = {
|
||||
PLANNED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
IN_PROGRESS: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
BLOCKED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
COMPLETE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
};
|
||||
|
||||
export const emptyProjectInput: ProjectInput = {
|
||||
name: "",
|
||||
status: "PLANNED",
|
||||
priority: "MEDIUM",
|
||||
customerId: "",
|
||||
salesQuoteId: null,
|
||||
salesOrderId: null,
|
||||
shipmentId: null,
|
||||
ownerId: null,
|
||||
dueDate: null,
|
||||
notes: "",
|
||||
milestones: [],
|
||||
};
|
||||
679
client/src/modules/purchasing/PurchaseDetailPage.tsx
Normal file
679
client/src/modules/purchasing/PurchaseDetailPage.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
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();
|
||||
const [document, setDocument] = useState<PurchaseOrderDetailDto | null>(null);
|
||||
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||
const [receiptForm, setReceiptForm] = useState<PurchaseReceiptInput>(emptyPurchaseReceiptInput);
|
||||
const [receiptQuantities, setReceiptQuantities] = useState<Record<string, number>>({});
|
||||
const [receiptStatus, setReceiptStatus] = useState("Receive ordered material into inventory against this purchase order.");
|
||||
const [isSavingReceipt, setIsSavingReceipt] = useState(false);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getPurchaseOrder(token, orderId)
|
||||
.then((nextDocument) => {
|
||||
setDocument(nextDocument);
|
||||
setStatus("Purchase order loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
|
||||
setStatus(message);
|
||||
});
|
||||
api.getDemandPlanningRollup(token).then(setPlanningRollup).catch(() => setPlanningRollup(null));
|
||||
|
||||
if (!canReceive) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getWarehouseLocationOptions(token)
|
||||
.then((options) => {
|
||||
setLocationOptions(options);
|
||||
setReceiptForm((current: PurchaseReceiptInput) => {
|
||||
if (current.locationId) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const firstOption = options[0];
|
||||
return firstOption
|
||||
? {
|
||||
...current,
|
||||
warehouseId: firstOption.warehouseId,
|
||||
locationId: firstOption.locationId,
|
||||
}
|
||||
: current;
|
||||
});
|
||||
})
|
||||
.catch(() => setLocationOptions([]));
|
||||
}, [canReceive, orderId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReceiptQuantities((current) => {
|
||||
const next: Record<string, number> = {};
|
||||
for (const line of document.lines) {
|
||||
if (line.remainingQuantity > 0) {
|
||||
next[line.id] = current[line.id] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [document]);
|
||||
|
||||
if (!document) {
|
||||
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 }));
|
||||
}
|
||||
|
||||
function updateReceiptQuantity(lineId: string, quantity: number) {
|
||||
setReceiptQuantities((current: Record<string, number>) => ({
|
||||
...current,
|
||||
[lineId]: quantity,
|
||||
}));
|
||||
}
|
||||
|
||||
async function applyStatusChange(nextStatus: PurchaseOrderStatus) {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingStatus(true);
|
||||
setStatus("Updating purchase order status...");
|
||||
|
||||
try {
|
||||
const nextDocument = await api.updatePurchaseOrderStatus(token, activeDocument.id, nextStatus);
|
||||
setDocument(nextDocument);
|
||||
setStatus("Purchase order status updated. 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);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyReceipt() {
|
||||
if (!token || !canReceive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingReceipt(true);
|
||||
setReceiptStatus("Posting purchase receipt...");
|
||||
|
||||
try {
|
||||
const payload: PurchaseReceiptInput = {
|
||||
...receiptForm,
|
||||
lines: openLines
|
||||
.map((line) => ({
|
||||
purchaseOrderLineId: line.id,
|
||||
quantity: Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)),
|
||||
}))
|
||||
.filter((line) => line.quantity > 0),
|
||||
};
|
||||
|
||||
const nextDocument = await api.createPurchaseReceipt(token, activeDocument.id, payload);
|
||||
setDocument(nextDocument);
|
||||
setReceiptQuantities({});
|
||||
setReceiptForm((current: PurchaseReceiptInput) => ({
|
||||
...current,
|
||||
receivedAt: new Date().toISOString(),
|
||||
notes: "",
|
||||
}));
|
||||
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.";
|
||||
setReceiptStatus(message);
|
||||
} finally {
|
||||
setIsSavingReceipt(false);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setIsOpeningPdf(true);
|
||||
setStatus("Rendering purchase order PDF...");
|
||||
|
||||
try {
|
||||
const blob = await api.getPurchaseOrderPdf(token, activeDocument.id);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
setStatus("Purchase order PDF ready.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to render purchase order PDF.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsOpeningPdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<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">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">
|
||||
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Back to purchase orders
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPdf}
|
||||
disabled={isOpeningPdf}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
|
||||
</button>
|
||||
{canManage ? (
|
||||
<Link to={`/purchasing/orders/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||
Edit purchase order
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="section-kicker">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{purchaseStatusOptions.map((option) => (
|
||||
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || activeDocument.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-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-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="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="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="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-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">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>
|
||||
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
|
||||
<td className="px-2 py-2 text-muted">${line.unitCost.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
|
||||
{canReceive ? (
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">PURCHASE RECEIVING</p>
|
||||
{openLines.length === 0 ? (
|
||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||
All ordered quantities have been received for this purchase order.
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<input
|
||||
type="date"
|
||||
value={receiptForm.receivedAt.slice(0, 10)}
|
||||
onChange={(event) => updateReceiptField("receivedAt", new Date(event.target.value).toISOString())}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
|
||||
<select
|
||||
value={receiptForm.locationId}
|
||||
onChange={(event) => {
|
||||
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
|
||||
updateReceiptField("locationId", event.target.value);
|
||||
if (nextLocation) {
|
||||
updateReceiptField("warehouseId", nextLocation.warehouseId);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{locationOptions.map((option) => (
|
||||
<option key={option.locationId} value={option.locationId}>
|
||||
{option.warehouseCode} / {option.locationCode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||
<textarea
|
||||
value={receiptForm.notes}
|
||||
onChange={(event) => updateReceiptField("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="space-y-3">
|
||||
{openLines.map((line) => (
|
||||
<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>
|
||||
<div className="mt-2 text-xs text-muted">{line.description}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Remaining</div>
|
||||
<div className="mt-2 font-semibold text-text">{line.remainingQuantity}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Received</div>
|
||||
<div className="mt-2 font-semibold text-text">{line.receivedQuantity}</div>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Receive Now</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={line.remainingQuantity}
|
||||
step={1}
|
||||
value={receiptQuantities[line.id] ?? 0}
|
||||
onChange={(event) => updateReceiptQuantity(line.id, Number.parseInt(event.target.value, 10) || 0)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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"
|
||||
disabled={isSavingReceipt || locationOptions.length === 0}
|
||||
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSavingReceipt ? "Posting..." : "Post receipt"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
) : null}
|
||||
<article className="surface-panel">
|
||||
<p className="section-kicker">RECEIPT HISTORY</p>
|
||||
{activeDocument.receipts.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 recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{activeDocument.receipts.map((receipt) => (
|
||||
<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>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{receipt.warehouseCode} / {receipt.locationCode} · {receipt.totalQuantity} units across {receipt.lineCount} line{receipt.lineCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">
|
||||
{receipt.warehouseName} · {receipt.locationName}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
{receipt.lines.map((line) => (
|
||||
<div key={line.id} className="text-sm text-text">
|
||||
<span className="font-semibold">{line.itemSku}</span> · {line.quantity}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{receipt.notes ? <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{receipt.notes}</p> : null}
|
||||
</div>
|
||||
<div className="text-sm text-muted lg:text-right">
|
||||
<div>{new Date(receipt.receivedAt).toLocaleDateString()}</div>
|
||||
<div className="mt-1">{receipt.createdByName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
<FileAttachmentsPanel
|
||||
ownerType="PURCHASE_ORDER"
|
||||
ownerId={activeDocument.id}
|
||||
eyebrow="Supporting Documents"
|
||||
title="Vendor invoices and backup"
|
||||
description="Store vendor invoices, acknowledgements, certifications, and supporting procurement documents directly on the purchase order."
|
||||
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet."
|
||||
/>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
515
client/src/modules/purchasing/PurchaseFormPage.tsx
Normal file
515
client/src/modules/purchasing/PurchaseFormPage.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
import { emptyPurchaseOrderInput, purchaseStatusOptions } from "./config";
|
||||
|
||||
export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const { token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
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[]>([]);
|
||||
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
|
||||
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
|
||||
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
|
||||
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
|
||||
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [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);
|
||||
const total = subtotal + taxAmount + form.freightAmount;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getPurchaseVendors(token).then((nextVendors) => {
|
||||
setVendors(nextVendors);
|
||||
if (mode === "create" && seededVendorId) {
|
||||
const seededVendor = nextVendors.find((vendor) => vendor.id === seededVendorId);
|
||||
if (seededVendor) {
|
||||
setForm((current: PurchaseOrderInput) => ({ ...current, vendorId: seededVendor.id }));
|
||||
setVendorSearchTerm(seededVendor.name);
|
||||
}
|
||||
}
|
||||
}).catch(() => setVendors([]));
|
||||
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
|
||||
}, [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;
|
||||
}
|
||||
|
||||
api.getPurchaseOrder(token, orderId)
|
||||
.then((document) => {
|
||||
setForm({
|
||||
vendorId: document.vendorId,
|
||||
projectId: document.projectId,
|
||||
status: document.status,
|
||||
issueDate: document.issueDate,
|
||||
taxPercent: document.taxPercent,
|
||||
freightAmount: document.freightAmount,
|
||||
notes: document.notes,
|
||||
revisionReason: "",
|
||||
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
|
||||
itemId: line.itemId,
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
unitOfMeasure: line.unitOfMeasure,
|
||||
unitCost: line.unitCost,
|
||||
salesOrderId: line.salesOrderId,
|
||||
salesOrderLineId: line.salesOrderLineId,
|
||||
position: line.position,
|
||||
})),
|
||||
});
|
||||
setVendorSearchTerm(document.vendorName);
|
||||
setLineSearchTerms(document.lines.map((line: { itemSku: string }) => line.itemSku));
|
||||
setStatus("Purchase order loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [mode, orderId, token]);
|
||||
|
||||
function updateField<Key extends keyof PurchaseOrderInput>(key: Key, value: PurchaseOrderInput[Key]) {
|
||||
setForm((current: PurchaseOrderInput) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function getSelectedVendorName(vendorId: string) {
|
||||
return vendors.find((vendor) => vendor.id === vendorId)?.name ?? "";
|
||||
}
|
||||
|
||||
function getSelectedVendor(vendorId: string) {
|
||||
return vendors.find((vendor) => vendor.id === vendorId) ?? null;
|
||||
}
|
||||
|
||||
function updateLine(index: number, nextLine: PurchaseLineInput) {
|
||||
setForm((current: PurchaseOrderInput) => ({
|
||||
...current,
|
||||
lines: current.lines.map((line: PurchaseLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateLineSearchTerm(index: number, value: string) {
|
||||
setLineSearchTerms((current) => {
|
||||
const next = [...current];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setForm((current: PurchaseOrderInput) => ({
|
||||
...current,
|
||||
lines: [
|
||||
...current.lines,
|
||||
{
|
||||
itemId: "",
|
||||
description: "",
|
||||
quantity: 1,
|
||||
unitOfMeasure: "EA",
|
||||
unitCost: 0,
|
||||
position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: PurchaseLineInput) => line.position)) + 10,
|
||||
},
|
||||
],
|
||||
}));
|
||||
setLineSearchTerms((current) => [...current, ""]);
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
setForm((current: PurchaseOrderInput) => ({
|
||||
...current,
|
||||
lines: current.lines.filter((_line: PurchaseLineInput, lineIndex: number) => lineIndex !== index),
|
||||
}));
|
||||
setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index));
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving purchase order...");
|
||||
|
||||
try {
|
||||
const saved = mode === "create" ? await api.createPurchaseOrder(token, form) : await api.updatePurchaseOrder(token, orderId ?? "", form);
|
||||
navigate(`/purchasing/orders/${saved.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save purchase order.";
|
||||
setStatus(message);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredVendorCount = vendors.filter((vendor) => {
|
||||
const query = vendorSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
||||
}).length;
|
||||
|
||||
function closeEditor() {
|
||||
navigate(mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="section-kicker">PURCHASING EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={vendorSearchTerm}
|
||||
onChange={(event) => {
|
||||
setVendorSearchTerm(event.target.value);
|
||||
updateField("vendorId", "");
|
||||
setVendorPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setVendorPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setVendorPickerOpen(false);
|
||||
if (form.vendorId) {
|
||||
setVendorSearchTerm(getSelectedVendorName(form.vendorId));
|
||||
}
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="Search vendor"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{vendorPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
{vendors
|
||||
.filter((vendor) => {
|
||||
const query = vendorSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((vendor) => (
|
||||
<button
|
||||
key={vendor.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("vendorId", vendor.id);
|
||||
setVendorSearchTerm(vendor.name);
|
||||
setVendorPickerOpen(false);
|
||||
}}
|
||||
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
|
||||
>
|
||||
<div className="font-semibold text-text">{vendor.name}</div>
|
||||
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredVendorCount === 0 ? <div className="px-2 py-2 text-sm text-muted">No matching vendors found.</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 min-h-5 text-xs text-muted">{form.vendorId ? getSelectedVendorName(form.vendorId) : "No vendor selected"}</div>
|
||||
{form.vendorId ? (
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
Terms: {getSelectedVendor(form.vendorId)?.paymentTerms || "N/A"} | Currency: {getSelectedVendor(form.vendorId)?.currencyCode || "USD"}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select value={form.status} onChange={(event) => updateField("status", event.target.value as PurchaseOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{purchaseStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Issue date</span>
|
||||
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</div>
|
||||
<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-[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>
|
||||
<input type="number" min={0} max={100} step={0.01} value={form.taxPercent} onChange={(event) => updateField("taxPercent", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Freight</span>
|
||||
<input type="number" min={0} step={0.01} value={form.freightAmount} onChange={(event) => updateField("freightAmount", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<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-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 space-y-3">
|
||||
{form.lines.map((line: PurchaseLineInput, index: number) => (
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={lineSearchTerms[index] ?? ""}
|
||||
onChange={(event) => {
|
||||
updateLineSearchTerm(index, event.target.value);
|
||||
updateLine(index, { ...line, itemId: "" });
|
||||
setActiveLinePicker(index);
|
||||
}}
|
||||
onFocus={() => setActiveLinePicker(index)}
|
||||
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
|
||||
placeholder="Search by SKU"
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{activeLinePicker === index ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
{itemOptions
|
||||
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
|
||||
.slice(0, 12)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateLine(index, {
|
||||
...line,
|
||||
itemId: option.id,
|
||||
description: line.description || option.name,
|
||||
salesOrderId: line.salesOrderId ?? null,
|
||||
salesOrderLineId: line.salesOrderLineId ?? null,
|
||||
});
|
||||
updateLineSearchTerm(index, option.sku);
|
||||
setActiveLinePicker(null);
|
||||
}}
|
||||
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
|
||||
>
|
||||
{option.sku}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
|
||||
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
|
||||
<select value={line.unitOfMeasure} onChange={(event) => updateLine(index, { ...line, unitOfMeasure: event.target.value as PurchaseLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{inventoryUnitOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Unit Cost</span>
|
||||
<input type="number" min={0} step={0.01} value={line.unitCost} onChange={(event) => updateLine(index, { ...line, unitCost: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="flex items-end"><div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">${(line.quantity * line.unitCost).toFixed(2)}</div></div>
|
||||
<div className="flex items-end"><button type="button" onClick={() => 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-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-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>
|
||||
);
|
||||
}
|
||||
|
||||
99
client/src/modules/purchasing/PurchaseListPage.tsx
Normal file
99
client/src/modules/purchasing/PurchaseListPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { PurchaseOrderStatus, PurchaseOrderSummaryDto } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { purchaseStatusFilters } from "./config";
|
||||
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
|
||||
|
||||
export function PurchaseListPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [documents, setDocuments] = useState<PurchaseOrderSummaryDto[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | PurchaseOrderStatus>("ALL");
|
||||
const [status, setStatus] = useState("Loading purchase orders...");
|
||||
|
||||
const canManage = user?.permissions.includes("purchasing.write") ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getPurchaseOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
|
||||
.then((nextDocuments) => {
|
||||
setDocuments(nextDocuments);
|
||||
setStatus(`${nextDocuments.length} purchase orders matched the current filters.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load purchase orders.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [searchTerm, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<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">
|
||||
New purchase order
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<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" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | PurchaseOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{purchaseStatusFilters.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-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-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-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">Document</th>
|
||||
<th className="px-2 py-2">Vendor</th>
|
||||
<th className="px-2 py-2">Status</th>
|
||||
<th className="px-2 py-2">Issue Date</th>
|
||||
<th className="px-2 py-2">Value</th>
|
||||
<th className="px-2 py-2">Lines</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{documents.map((document) => (
|
||||
<tr key={document.id} className="transition hover:bg-page/70">
|
||||
<td className="px-2 py-2"><Link to={`/purchasing/orders/${document.id}`} className="font-semibold text-text hover:text-brand">{document.documentNumber}</Link></td>
|
||||
<td className="px-2 py-2 text-muted">{document.vendorName}</td>
|
||||
<td className="px-2 py-2"><PurchaseStatusBadge status={document.status} /></td>
|
||||
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
|
||||
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
7
client/src/modules/purchasing/PurchaseStatusBadge.tsx
Normal file
7
client/src/modules/purchasing/PurchaseStatusBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PurchaseOrderStatus } from "@mrp/shared";
|
||||
|
||||
import { purchaseStatusPalette } from "./config";
|
||||
|
||||
export function PurchaseStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${purchaseStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
|
||||
}
|
||||
41
client/src/modules/purchasing/config.ts
Normal file
41
client/src/modules/purchasing/config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { PurchaseOrderInput, PurchaseOrderStatus } from "@mrp/shared";
|
||||
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||
|
||||
export const purchaseStatusOptions: Array<{ value: PurchaseOrderStatus; label: string }> = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "ISSUED", label: "Issued" },
|
||||
{ value: "APPROVED", label: "Approved" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
];
|
||||
|
||||
export const purchaseStatusFilters: Array<{ value: "ALL" | PurchaseOrderStatus; label: string }> = [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...purchaseStatusOptions,
|
||||
];
|
||||
|
||||
export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
|
||||
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
};
|
||||
|
||||
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
||||
vendorId: "",
|
||||
projectId: null,
|
||||
status: "DRAFT",
|
||||
issueDate: new Date().toISOString(),
|
||||
taxPercent: 0,
|
||||
freightAmount: 0,
|
||||
notes: "",
|
||||
revisionReason: "",
|
||||
lines: [],
|
||||
};
|
||||
|
||||
export const emptyPurchaseReceiptInput: PurchaseReceiptInput = {
|
||||
receivedAt: new Date().toISOString(),
|
||||
warehouseId: "",
|
||||
locationId: "",
|
||||
notes: "",
|
||||
lines: [],
|
||||
};
|
||||
780
client/src/modules/sales/SalesDetailPage.tsx
Normal file
780
client/src/modules/sales/SalesDetailPage.tsx
Normal file
@@ -0,0 +1,780 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
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();
|
||||
const { quoteId, orderId } = useParams();
|
||||
const config = salesConfigs[entity];
|
||||
const documentId = entity === "quote" ? quoteId : orderId;
|
||||
const [document, setDocument] = useState<SalesDocumentDetailDto | null>(null);
|
||||
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
|
||||
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([]));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
});
|
||||
}, [canReadShipping, config.singularLabel, documentId, entity, token]);
|
||||
|
||||
if (!document) {
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setIsUpdatingStatus(true);
|
||||
setStatus(`Updating ${config.singularLabel.toLowerCase()} status...`);
|
||||
|
||||
try {
|
||||
const nextDocument =
|
||||
entity === "quote"
|
||||
? await api.updateQuoteStatus(token, activeDocument.id, nextStatus)
|
||||
: await api.updateSalesOrderStatus(token, activeDocument.id, nextStatus);
|
||||
setDocument(nextDocument);
|
||||
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);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyConvert() {
|
||||
if (!token || entity !== "quote") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConverting(true);
|
||||
setStatus("Converting quote to sales order...");
|
||||
|
||||
try {
|
||||
const order = await api.convertQuoteToSalesOrder(token, activeDocument.id);
|
||||
navigate(`/sales/orders/${order.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to convert quote to sales order.";
|
||||
setStatus(message);
|
||||
setIsConverting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenPdf() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpeningPdf(true);
|
||||
setStatus(`Rendering ${config.singularLabel.toLowerCase()} PDF...`);
|
||||
|
||||
try {
|
||||
const blob =
|
||||
entity === "quote"
|
||||
? await api.getQuotePdf(token, activeDocument.id)
|
||||
: await api.getSalesOrderPdf(token, activeDocument.id);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
setStatus(`${config.singularLabel} PDF ready.`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to render ${config.singularLabel.toLowerCase()} PDF.`;
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsOpeningPdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyApprove() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApproving(true);
|
||||
setStatus(`Approving ${config.singularLabel.toLowerCase()}...`);
|
||||
|
||||
try {
|
||||
const nextDocument =
|
||||
entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id);
|
||||
setDocument(nextDocument);
|
||||
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);
|
||||
} finally {
|
||||
setIsApproving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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="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">{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} />
|
||||
<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.currentRevisionNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to={config.routeBase} 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 {config.collectionLabel.toLowerCase()}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPdf}
|
||||
disabled={isOpeningPdf}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
|
||||
</button>
|
||||
{canManage ? (
|
||||
<>
|
||||
<Link to={`${config.routeBase}/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||
Edit {config.singularLabel.toLowerCase()}
|
||||
</Link>
|
||||
{activeDocument.status !== "APPROVED" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApprove}
|
||||
disabled={isApproving}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
|
||||
>
|
||||
{isApproving ? "Approving..." : "Approve"}
|
||||
</button>
|
||||
) : null}
|
||||
{entity === "quote" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConvert}
|
||||
disabled={isConverting}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
|
||||
>
|
||||
{isConverting ? "Converting..." : "Convert to sales order"}
|
||||
</button>
|
||||
) : null}
|
||||
{entity === "order" && 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">
|
||||
New shipment
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<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">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{salesStatusOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
disabled={isUpdatingStatus || activeDocument.status === option.value}
|
||||
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-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">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="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">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-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="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">Total</p>
|
||||
<div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</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 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="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>
|
||||
<dd className="mt-1 text-sm text-text">{activeDocument.customerName}</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.customerEmail}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<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="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">Qty</th>
|
||||
<th className="px-2 py-2">UOM</th>
|
||||
<th className="px-2 py-2">Unit Price</th>
|
||||
<th className="px-2 py-2">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{activeDocument.lines.map((line: SalesDocumentDetailDto["lines"][number]) => (
|
||||
<tr key={line.id}>
|
||||
<td className="px-2 py-2">
|
||||
<div className="font-semibold text-text">{line.itemSku}</div>
|
||||
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{line.description}</td>
|
||||
<td className="px-2 py-2 text-muted">{line.quantity}</td>
|
||||
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
|
||||
<td className="px-2 py-2 text-muted">${line.unitPrice.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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="surface-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<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">
|
||||
Create shipment
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{shipments.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 shipments created yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{shipments.map((shipment) => (
|
||||
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
|
||||
<div className="mt-1 text-xs text-muted">{shipment.carrier || "Carrier not set"} · {shipment.trackingNumber || "No tracking"}</div>
|
||||
</div>
|
||||
<ShipmentStatusBadge status={shipment.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
506
client/src/modules/sales/SalesFormPage.tsx
Normal file
506
client/src/modules/sales/SalesFormPage.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
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 { 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";
|
||||
import { emptySalesDocumentInput, salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
|
||||
|
||||
export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; mode: "create" | "edit" }) {
|
||||
const { token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { quoteId, orderId } = useParams();
|
||||
const documentId = entity === "quote" ? quoteId : orderId;
|
||||
const config = salesConfigs[entity];
|
||||
const [form, setForm] = useState<SalesDocumentInput>(emptySalesDocumentInput);
|
||||
const [status, setStatus] = useState(mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()}.` : `Loading ${config.singularLabel.toLowerCase()}...`);
|
||||
const [customers, setCustomers] = useState<SalesCustomerOptionDto[]>([]);
|
||||
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
|
||||
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
|
||||
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
|
||||
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
|
||||
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [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);
|
||||
const taxableSubtotal = subtotal - discountAmount;
|
||||
const taxAmount = taxableSubtotal * (form.taxPercent / 100);
|
||||
const total = taxableSubtotal + taxAmount + form.freightAmount;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getSalesCustomers(token).then(setCustomers).catch(() => setCustomers([]));
|
||||
api.getInventoryItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || mode !== "edit" || !documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
|
||||
loader
|
||||
.then((document) => {
|
||||
setForm({
|
||||
customerId: document.customerId,
|
||||
status: document.status,
|
||||
issueDate: document.issueDate,
|
||||
expiresAt: entity === "quote" ? document.expiresAt : null,
|
||||
discountPercent: document.discountPercent,
|
||||
taxPercent: document.taxPercent,
|
||||
freightAmount: document.freightAmount,
|
||||
notes: document.notes,
|
||||
revisionReason: "",
|
||||
lines: document.lines.map((line) => ({
|
||||
itemId: line.itemId,
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
unitOfMeasure: line.unitOfMeasure,
|
||||
unitPrice: line.unitPrice,
|
||||
position: line.position,
|
||||
})),
|
||||
});
|
||||
setCustomerSearchTerm(document.customerName);
|
||||
setLineSearchTerms(document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => line.itemSku));
|
||||
setStatus(`${config.singularLabel} loaded.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
});
|
||||
}, [config.singularLabel, documentId, entity, mode, token]);
|
||||
|
||||
function updateField<Key extends keyof SalesDocumentInput>(key: Key, value: SalesDocumentInput[Key]) {
|
||||
setForm((current: SalesDocumentInput) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function getSelectedCustomerName(customerId: string) {
|
||||
return customers.find((customer) => customer.id === customerId)?.name ?? "";
|
||||
}
|
||||
|
||||
function getSelectedCustomer(customerId: string) {
|
||||
return customers.find((customer) => customer.id === customerId) ?? null;
|
||||
}
|
||||
|
||||
function updateLine(index: number, nextLine: SalesLineInput) {
|
||||
setForm((current: SalesDocumentInput) => ({
|
||||
...current,
|
||||
lines: current.lines.map((line: SalesLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateLineSearchTerm(index: number, value: string) {
|
||||
setLineSearchTerms((current: string[]) => {
|
||||
const next = [...current];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setForm((current: SalesDocumentInput) => ({
|
||||
...current,
|
||||
lines: [
|
||||
...current.lines,
|
||||
{
|
||||
itemId: "",
|
||||
description: "",
|
||||
quantity: 1,
|
||||
unitOfMeasure: "EA",
|
||||
unitPrice: 0,
|
||||
position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: SalesLineInput) => line.position)) + 10,
|
||||
},
|
||||
],
|
||||
}));
|
||||
setLineSearchTerms((current: string[]) => [...current, ""]);
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
setForm((current: SalesDocumentInput) => ({
|
||||
...current,
|
||||
lines: current.lines.filter((_line: SalesLineInput, lineIndex: number) => lineIndex !== index),
|
||||
}));
|
||||
setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index));
|
||||
}
|
||||
|
||||
const pendingLineRemoval =
|
||||
pendingLineRemovalIndex != null
|
||||
? {
|
||||
index: pendingLineRemovalIndex,
|
||||
line: form.lines[pendingLineRemovalIndex],
|
||||
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
|
||||
}
|
||||
: null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
|
||||
|
||||
try {
|
||||
const saved =
|
||||
entity === "quote"
|
||||
? mode === "create"
|
||||
? await api.createQuote(token, form)
|
||||
: await api.updateQuote(token, documentId ?? "", form)
|
||||
: mode === "create"
|
||||
? await api.createSalesOrder(token, { ...form, expiresAt: null })
|
||||
: await api.updateSalesOrder(token, documentId ?? "", { ...form, expiresAt: null });
|
||||
|
||||
navigate(`${config.routeBase}/${saved.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
navigate(mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="section-kicker">{`${config.detailEyebrow} EDITOR`.toUpperCase()}</p>
|
||||
<h3 className="module-title">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={customerSearchTerm}
|
||||
onChange={(event) => {
|
||||
setCustomerSearchTerm(event.target.value);
|
||||
updateField("customerId", "");
|
||||
setCustomerPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setCustomerPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setCustomerPickerOpen(false);
|
||||
if (form.customerId) {
|
||||
setCustomerSearchTerm(getSelectedCustomerName(form.customerId));
|
||||
}
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="Search customer"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{customerPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
{customers
|
||||
.filter((customer) => {
|
||||
const query = customerSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
customer.name.toLowerCase().includes(query) ||
|
||||
customer.email.toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((customer) => (
|
||||
<button
|
||||
key={customer.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("customerId", customer.id);
|
||||
updateField("discountPercent", customer.resellerDiscountPercent);
|
||||
setCustomerSearchTerm(customer.name);
|
||||
setCustomerPickerOpen(false);
|
||||
}}
|
||||
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
|
||||
>
|
||||
<div className="font-semibold text-text">{customer.name}</div>
|
||||
<div className="mt-1 text-xs text-muted">{customer.email}</div>
|
||||
</button>
|
||||
))}
|
||||
{customers.filter((customer) => {
|
||||
const query = customerSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
|
||||
}).length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted">No matching customers found.</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 min-h-5 text-xs text-muted">
|
||||
{form.customerId ? getSelectedCustomerName(form.customerId) : "No customer selected"}
|
||||
</div>
|
||||
{form.customerId ? (
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
Default reseller discount: {getSelectedCustomer(form.customerId)?.resellerDiscountPercent.toFixed(2) ?? "0.00"}%
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => updateField("status", event.target.value as SalesDocumentInput["status"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{salesStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Issue date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={form.issueDate.slice(0, 10)}
|
||||
onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{entity === "quote" ? (
|
||||
<label className="block xl:max-w-sm">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Expiration date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={form.expiresAt ? form.expiresAt.slice(0, 10) : ""}
|
||||
onChange={(event) => updateField("expiresAt", 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>
|
||||
) : null}
|
||||
<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-[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-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Discount %</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
value={form.discountPercent}
|
||||
onChange={(event) => updateField("discountPercent", Number(event.target.value) || 0)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
value={form.taxPercent}
|
||||
onChange={(event) => updateField("taxPercent", Number(event.target.value) || 0)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Freight</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={form.freightAmount}
|
||||
onChange={(event) => updateField("freightAmount", Number(event.target.value) || 0)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<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-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 space-y-3">
|
||||
{form.lines.map((line: SalesLineInput, index: number) => (
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={lineSearchTerms[index] ?? ""}
|
||||
onChange={(event) => {
|
||||
updateLineSearchTerm(index, event.target.value);
|
||||
updateLine(index, { ...line, itemId: "" });
|
||||
setActiveLinePicker(index);
|
||||
}}
|
||||
onFocus={() => setActiveLinePicker(index)}
|
||||
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
|
||||
placeholder="Search by SKU"
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{activeLinePicker === index ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
{itemOptions
|
||||
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
|
||||
.slice(0, 12)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateLine(index, {
|
||||
...line,
|
||||
itemId: option.id,
|
||||
description: line.description || option.name,
|
||||
unitPrice: line.unitPrice > 0 ? line.unitPrice : (option.defaultPrice ?? 0),
|
||||
});
|
||||
updateLineSearchTerm(index, option.sku);
|
||||
setActiveLinePicker(null);
|
||||
}}
|
||||
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
|
||||
>
|
||||
{option.sku}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
|
||||
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
|
||||
<select value={line.unitOfMeasure} onChange={(event) => updateLine(index, { ...line, unitOfMeasure: event.target.value as SalesLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{inventoryUnitOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Unit Price</span>
|
||||
<input type="number" min={0} step={0.01} value={line.unitPrice} onChange={(event) => updateLine(index, { ...line, unitPrice: 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.unitPrice).toFixed(2)}
|
||||
</div>
|
||||
</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-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>
|
||||
</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">Discount</div>
|
||||
<div className="mt-1 font-semibold text-text">-${discountAmount.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 + Freight</div>
|
||||
<div className="mt-1 font-semibold text-text">${(taxAmount + 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-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>
|
||||
);
|
||||
}
|
||||
|
||||
127
client/src/modules/sales/SalesListPage.tsx
Normal file
127
client/src/modules/sales/SalesListPage.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { SalesDocumentStatus, SalesDocumentSummaryDto } from "@mrp/shared/dist/sales/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { salesConfigs, salesStatusFilters, type SalesDocumentEntity } from "./config";
|
||||
import { SalesStatusBadge } from "./SalesStatusBadge";
|
||||
|
||||
export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
const { token, user } = useAuth();
|
||||
const config = salesConfigs[entity];
|
||||
const [documents, setDocuments] = useState<SalesDocumentSummaryDto[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | SalesDocumentStatus>("ALL");
|
||||
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader =
|
||||
entity === "quote"
|
||||
? api.getQuotes(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
|
||||
: api.getSalesOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter });
|
||||
|
||||
loader
|
||||
.then((nextDocuments) => {
|
||||
setDocuments(nextDocuments);
|
||||
setStatus(`${nextDocuments.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
});
|
||||
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<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">{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">
|
||||
New {config.singularLabel.toLowerCase()}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<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 ${config.collectionLabel.toLowerCase()} by document number or customer`}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "ALL" | SalesDocumentStatus)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{salesStatusFilters.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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-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-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">Document</th>
|
||||
<th className="px-2 py-2">Customer</th>
|
||||
<th className="px-2 py-2">Status</th>
|
||||
<th className="px-2 py-2">Revision</th>
|
||||
<th className="px-2 py-2">Issue Date</th>
|
||||
<th className="px-2 py-2">Value</th>
|
||||
<th className="px-2 py-2">Lines</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{documents.map((document) => (
|
||||
<tr key={document.id} className="transition hover:bg-page/70">
|
||||
<td className="px-2 py-2">
|
||||
<Link to={`${config.routeBase}/${document.id}`} className="font-semibold text-text hover:text-brand">
|
||||
{document.documentNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{document.customerName}</td>
|
||||
<td className="px-2 py-2">
|
||||
<SalesStatusBadge status={document.status} />
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">
|
||||
Rev {document.currentRevisionNumber}
|
||||
{document.approvedAt ? <div className="mt-1 text-xs text-muted">Approved</div> : null}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
|
||||
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
9
client/src/modules/sales/SalesStatusBadge.tsx
Normal file
9
client/src/modules/sales/SalesStatusBadge.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
|
||||
|
||||
import { salesStatusOptions, salesStatusPalette } from "./config";
|
||||
|
||||
export function SalesStatusBadge({ status }: { status: SalesDocumentStatus }) {
|
||||
const label = salesStatusOptions.find((option) => option.value === status)?.label ?? status;
|
||||
|
||||
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${salesStatusPalette[status]}`}>{label}</span>;
|
||||
}
|
||||
63
client/src/modules/sales/config.ts
Normal file
63
client/src/modules/sales/config.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { SalesDocumentInput, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
|
||||
|
||||
export type SalesDocumentEntity = "quote" | "order";
|
||||
|
||||
interface SalesModuleConfig {
|
||||
entity: SalesDocumentEntity;
|
||||
singularLabel: string;
|
||||
collectionLabel: string;
|
||||
routeBase: string;
|
||||
detailEyebrow: string;
|
||||
listEyebrow: string;
|
||||
}
|
||||
|
||||
export const salesConfigs: Record<SalesDocumentEntity, SalesModuleConfig> = {
|
||||
quote: {
|
||||
entity: "quote",
|
||||
singularLabel: "Quote",
|
||||
collectionLabel: "Quotes",
|
||||
routeBase: "/sales/quotes",
|
||||
detailEyebrow: "Sales Quote",
|
||||
listEyebrow: "Sales",
|
||||
},
|
||||
order: {
|
||||
entity: "order",
|
||||
singularLabel: "Sales Order",
|
||||
collectionLabel: "Sales Orders",
|
||||
routeBase: "/sales/orders",
|
||||
detailEyebrow: "Sales Order",
|
||||
listEyebrow: "Sales",
|
||||
},
|
||||
};
|
||||
|
||||
export const salesStatusOptions: Array<{ value: SalesDocumentStatus; label: string }> = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "ISSUED", label: "Issued" },
|
||||
{ value: "APPROVED", label: "Approved" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
];
|
||||
|
||||
export const salesStatusFilters: Array<{ value: "ALL" | SalesDocumentStatus; label: string }> = [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...salesStatusOptions,
|
||||
];
|
||||
|
||||
export const salesStatusPalette: Record<SalesDocumentStatus, string> = {
|
||||
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||
};
|
||||
|
||||
export const emptySalesDocumentInput: SalesDocumentInput = {
|
||||
customerId: "",
|
||||
status: "DRAFT",
|
||||
issueDate: new Date().toISOString(),
|
||||
expiresAt: null,
|
||||
discountPercent: 0,
|
||||
taxPercent: 0,
|
||||
freightAmount: 0,
|
||||
notes: "",
|
||||
lines: [],
|
||||
revisionReason: "",
|
||||
};
|
||||
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,21 +7,43 @@ 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);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string>("Loading company profile...");
|
||||
|
||||
async function loadLogoPreview(nextToken: string, logoFileId: string | null) {
|
||||
if (!logoFileId) {
|
||||
setLogoUrl((current) => {
|
||||
if (current?.startsWith("blob:")) {
|
||||
window.URL.revokeObjectURL(current);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await api.getFileContentBlob(nextToken, logoFileId);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
setLogoUrl((current) => {
|
||||
if (current?.startsWith("blob:")) {
|
||||
window.URL.revokeObjectURL(current);
|
||||
}
|
||||
return objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
api.getCompanyProfile(token).then((profile) => {
|
||||
setCompanyId(profile.id);
|
||||
setLogoUrl(profile.logoUrl);
|
||||
setForm({
|
||||
companyName: profile.companyName,
|
||||
legalName: profile.legalName,
|
||||
@@ -38,11 +61,39 @@ export function CompanySettingsPage() {
|
||||
});
|
||||
applyBrandProfile(profile);
|
||||
setStatus("Company profile loaded.");
|
||||
|
||||
if (profile.theme.logoFileId) {
|
||||
loadLogoPreview(token, profile.theme.logoFileId)
|
||||
.then(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setLogoUrl(null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLogoUrl(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [applyBrandProfile, token]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoUrl?.startsWith("blob:")) {
|
||||
window.URL.revokeObjectURL(logoUrl);
|
||||
}
|
||||
};
|
||||
}, [logoUrl]);
|
||||
|
||||
if (!form || !token) {
|
||||
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>;
|
||||
}
|
||||
|
||||
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -52,7 +103,7 @@ export function CompanySettingsPage() {
|
||||
}
|
||||
const profile = await api.updateCompanyProfile(token, form);
|
||||
applyBrandProfile(profile);
|
||||
setLogoUrl(profile.logoUrl);
|
||||
await loadLogoPreview(token, profile.theme.logoFileId);
|
||||
setStatus("Company settings saved.");
|
||||
}
|
||||
|
||||
@@ -74,7 +125,7 @@ export function CompanySettingsPage() {
|
||||
}
|
||||
: current
|
||||
);
|
||||
setLogoUrl(`/api/v1/files/${attachment.id}/content`);
|
||||
await loadLogoPreview(token, attachment.id);
|
||||
setStatus("Logo uploaded. Save to persist it on the profile.");
|
||||
}
|
||||
|
||||
@@ -94,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-8 shadow-panel backdrop-blur">
|
||||
<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-3 text-2xl 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
|
||||
@@ -110,7 +178,7 @@ export function CompanySettingsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-2">
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{[
|
||||
["companyName", "Company name"],
|
||||
["legalName", "Legal name"],
|
||||
@@ -126,47 +194,47 @@ 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-4 py-3 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-8 shadow-panel backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 xl: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-12 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-12 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-12 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>
|
||||
<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-4 py-3 text-text outline-none transition focus:border-brand" />
|
||||
<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-6 flex items-center justify-between rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
|
||||
<span className="text-sm text-muted">{status}</span>
|
||||
<div className="flex gap-3">
|
||||
<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
|
||||
type="button"
|
||||
onClick={handlePdfPreview}
|
||||
className="rounded-2xl border border-line/70 px-4 py-3 text-sm font-semibold text-text"
|
||||
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||
>
|
||||
Preview PDF
|
||||
</button>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white">
|
||||
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
@@ -175,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>
|
||||
);
|
||||
}
|
||||
|
||||
568
client/src/modules/shipping/ShipmentDetailPage.tsx
Normal file
568
client/src/modules/shipping/ShipmentDetailPage.tsx
Normal file
@@ -0,0 +1,568 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
loadShipmentDetail(token, shipmentId).catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [shipmentId, token, canManage]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setIsUpdatingStatus(true);
|
||||
setStatus("Updating shipment status...");
|
||||
try {
|
||||
const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus);
|
||||
setShipment(nextShipment);
|
||||
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);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setActiveDocumentAction(kind);
|
||||
setStatus(
|
||||
kind === "packing-slip"
|
||||
? "Rendering packing slip PDF..."
|
||||
: kind === "label"
|
||||
? "Rendering shipping label PDF..."
|
||||
: "Rendering bill of lading PDF..."
|
||||
);
|
||||
try {
|
||||
const blob =
|
||||
kind === "packing-slip"
|
||||
? await api.getShipmentPackingSlipPdf(token, shipment.id)
|
||||
: kind === "label"
|
||||
? await api.getShipmentLabelPdf(token, shipment.id)
|
||||
: await api.getShipmentBillOfLadingPdf(token, shipment.id);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
setStatus(
|
||||
kind === "packing-slip"
|
||||
? "Packing slip PDF rendered."
|
||||
: kind === "label"
|
||||
? "Shipping label PDF rendered."
|
||||
: "Bill of lading PDF rendered."
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: kind === "packing-slip"
|
||||
? "Unable to render packing slip PDF."
|
||||
: kind === "label"
|
||||
? "Unable to render shipping label PDF."
|
||||
: "Unable to render bill of lading PDF.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setActiveDocumentAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
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-[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="surface-panel">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<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>
|
||||
<Link to={`/sales/orders/${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">Open sales order</Link>
|
||||
<button type="button" onClick={() => handleOpenDocument("packing-slip")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{activeDocumentAction === "packing-slip" ? "Rendering PDF..." : "Open packing slip"}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleOpenDocument("label")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{activeDocumentAction === "label" ? "Rendering PDF..." : "Open shipping label"}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleOpenDocument("bol")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{activeDocumentAction === "bol" ? "Rendering PDF..." : "Open bill of lading"}
|
||||
</button>
|
||||
{canManage ? (
|
||||
<Link to={`/shipping/shipments/${shipment.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit shipment</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<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">QUICK ACTIONS</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{shipmentStatusOptions.map((option) => (
|
||||
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || shipment.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-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="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">{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>
|
||||
|
||||
{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="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-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-3 space-y-2">
|
||||
{relatedShipments.map((related) => (
|
||||
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||
<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>
|
||||
<ShipmentStatusBadge status={related.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FileAttachmentsPanel
|
||||
ownerType="SHIPMENT"
|
||||
ownerId={shipment.id}
|
||||
eyebrow="Logistics Attachments"
|
||||
title="Shipment files"
|
||||
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>
|
||||
);
|
||||
}
|
||||
206
client/src/modules/shipping/ShipmentFormPage.tsx
Normal file
206
client/src/modules/shipping/ShipmentFormPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyShipmentInput, shipmentStatusOptions } from "./config";
|
||||
|
||||
export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const { token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { shipmentId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const seededOrderId = searchParams.get("orderId") ?? "";
|
||||
const [form, setForm] = useState<ShipmentInput>({ ...emptyShipmentInput, salesOrderId: seededOrderId });
|
||||
const [orderOptions, setOrderOptions] = useState<ShipmentOrderOptionDto[]>([]);
|
||||
const [orderSearchTerm, setOrderSearchTerm] = useState("");
|
||||
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
|
||||
const [status, setStatus] = useState(mode === "create" ? "Create a new shipment." : "Loading shipment...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getShipmentOrderOptions(token).then((options) => {
|
||||
setOrderOptions(options);
|
||||
const seeded = options.find((option) => option.id === seededOrderId);
|
||||
if (seeded && mode === "create") {
|
||||
setOrderSearchTerm(`${seeded.documentNumber} - ${seeded.customerName}`);
|
||||
}
|
||||
}).catch(() => setOrderOptions([]));
|
||||
}, [mode, seededOrderId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || mode !== "edit" || !shipmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getShipment(token, shipmentId)
|
||||
.then((shipment) => {
|
||||
setForm({
|
||||
salesOrderId: shipment.salesOrderId,
|
||||
status: shipment.status,
|
||||
shipDate: shipment.shipDate,
|
||||
carrier: shipment.carrier,
|
||||
serviceLevel: shipment.serviceLevel,
|
||||
trackingNumber: shipment.trackingNumber,
|
||||
packageCount: shipment.packageCount,
|
||||
notes: shipment.notes,
|
||||
});
|
||||
setOrderSearchTerm(`${shipment.salesOrderNumber} - ${shipment.customerName}`);
|
||||
setStatus("Shipment loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [mode, shipmentId, token]);
|
||||
|
||||
function updateField<Key extends keyof ShipmentInput>(key: Key, value: ShipmentInput[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function getSelectedOrder(orderId: string) {
|
||||
return orderOptions.find((option) => option.id === orderId) ?? null;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving shipment...");
|
||||
try {
|
||||
const saved = mode === "create" ? await api.createShipment(token, form) : await api.updateShipment(token, shipmentId ?? "", form);
|
||||
navigate(`/shipping/shipments/${saved.id}`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save shipment.";
|
||||
setStatus(message);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
navigate(mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="section-kicker">SHIPPING EDITOR</p>
|
||||
<h3 className="module-title">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<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">
|
||||
<input
|
||||
value={orderSearchTerm}
|
||||
onChange={(event) => {
|
||||
setOrderSearchTerm(event.target.value);
|
||||
updateField("salesOrderId", "");
|
||||
setOrderPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setOrderPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setOrderPickerOpen(false);
|
||||
const selected = getSelectedOrder(form.salesOrderId);
|
||||
if (selected) {
|
||||
setOrderSearchTerm(`${selected.documentNumber} - ${selected.customerName}`);
|
||||
}
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="Search sales order"
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
{orderPickerOpen ? (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||
{orderOptions
|
||||
.filter((option) => {
|
||||
const query = orderSearchTerm.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return option.documentNumber.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
updateField("salesOrderId", option.id);
|
||||
setOrderSearchTerm(`${option.documentNumber} - ${option.customerName}`);
|
||||
setOrderPickerOpen(false);
|
||||
}}
|
||||
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
|
||||
>
|
||||
<div className="font-semibold text-text">{option.documentNumber}</div>
|
||||
<div className="mt-1 text-xs text-muted">{option.customerName}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<div className="grid gap-3 xl:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||
<select value={form.status} onChange={(event) => updateField("status", event.target.value as ShipmentInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
{shipmentStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Ship Date</span>
|
||||
<input type="date" value={form.shipDate ? form.shipDate.slice(0, 10) : ""} onChange={(event) => updateField("shipDate", 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>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Packages</span>
|
||||
<input type="number" min={1} step={1} value={form.packageCount} onChange={(event) => updateField("packageCount", 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>
|
||||
<div className="grid gap-3 xl:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Carrier</span>
|
||||
<input value={form.carrier} onChange={(event) => updateField("carrier", 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">Service Level</span>
|
||||
<input value={form.serviceLevel} onChange={(event) => updateField("serviceLevel", 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">Tracking Number</span>
|
||||
<input value={form.trackingNumber} onChange={(event) => updateField("trackingNumber", 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-[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>
|
||||
<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 shipment" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
116
client/src/modules/shipping/ShipmentListPage.tsx
Normal file
116
client/src/modules/shipping/ShipmentListPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { shipmentStatusFilters } from "./config";
|
||||
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
|
||||
|
||||
export function ShipmentListPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | ShipmentStatus>("ALL");
|
||||
const [status, setStatus] = useState("Loading shipments...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getShipments(token, {
|
||||
q: searchTerm.trim() || undefined,
|
||||
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||
})
|
||||
.then((nextShipments) => {
|
||||
setShipments(nextShipments);
|
||||
setStatus(`${nextShipments.length} shipments matched the current filters.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load shipments.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [searchTerm, statusFilter, token]);
|
||||
|
||||
return (
|
||||
<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">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">
|
||||
New shipment
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<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 by shipment, order, customer, carrier, or tracking"
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "ALL" | ShipmentStatus)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{shipmentStatusFilters.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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-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-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">Shipment</th>
|
||||
<th className="px-2 py-2">Sales Order</th>
|
||||
<th className="px-2 py-2">Customer</th>
|
||||
<th className="px-2 py-2">Status</th>
|
||||
<th className="px-2 py-2">Carrier</th>
|
||||
<th className="px-2 py-2">Ship Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70 bg-surface">
|
||||
{shipments.map((shipment) => (
|
||||
<tr key={shipment.id} className="transition hover:bg-page/70">
|
||||
<td className="px-2 py-2">
|
||||
<Link to={`/shipping/shipments/${shipment.id}`} className="font-semibold text-text hover:text-brand">
|
||||
{shipment.shipmentNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{shipment.salesOrderNumber}</td>
|
||||
<td className="px-2 py-2 text-muted">{shipment.customerName}</td>
|
||||
<td className="px-2 py-2"><ShipmentStatusBadge status={shipment.status} /></td>
|
||||
<td className="px-2 py-2 text-muted">{shipment.carrier || "Not set"}</td>
|
||||
<td className="px-2 py-2 text-muted">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
8
client/src/modules/shipping/ShipmentStatusBadge.tsx
Normal file
8
client/src/modules/shipping/ShipmentStatusBadge.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
|
||||
|
||||
import { shipmentStatusOptions, shipmentStatusPalette } from "./config";
|
||||
|
||||
export function ShipmentStatusBadge({ status }: { status: ShipmentStatus }) {
|
||||
const label = shipmentStatusOptions.find((option) => option.value === status)?.label ?? status;
|
||||
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${shipmentStatusPalette[status]}`}>{label}</span>;
|
||||
}
|
||||
5
client/src/modules/shipping/ShipmentsPage.tsx
Normal file
5
client/src/modules/shipping/ShipmentsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export function ShipmentsPage() {
|
||||
return <Outlet />;
|
||||
}
|
||||
33
client/src/modules/shipping/config.ts
Normal file
33
client/src/modules/shipping/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ShipmentInput, ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
|
||||
|
||||
export const shipmentStatusOptions: Array<{ value: ShipmentStatus; label: string }> = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "PICKING", label: "Picking" },
|
||||
{ value: "PACKED", label: "Packed" },
|
||||
{ value: "SHIPPED", label: "Shipped" },
|
||||
{ value: "DELIVERED", label: "Delivered" },
|
||||
];
|
||||
|
||||
export const shipmentStatusFilters: Array<{ value: "ALL" | ShipmentStatus; label: string }> = [
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...shipmentStatusOptions,
|
||||
];
|
||||
|
||||
export const shipmentStatusPalette: Record<ShipmentStatus, string> = {
|
||||
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||
PICKING: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||
PACKED: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||
SHIPPED: "border border-brand/30 bg-brand/10 text-brand",
|
||||
DELIVERED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
export const emptyShipmentInput: ShipmentInput = {
|
||||
salesOrderId: "",
|
||||
status: "DRAFT",
|
||||
shipDate: null,
|
||||
carrier: "",
|
||||
serviceLevel: "",
|
||||
trackingNumber: "",
|
||||
packageCount: 1,
|
||||
notes: "",
|
||||
};
|
||||
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", 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,7 @@
|
||||
ALTER TABLE "Customer" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
|
||||
|
||||
ALTER TABLE "Vendor" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
|
||||
|
||||
CREATE INDEX "Customer_status_idx" ON "Customer"("status");
|
||||
|
||||
CREATE INDEX "Vendor_status_idx" ON "Vendor"("status");
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "CrmContactEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL DEFAULT 'NOTE',
|
||||
"summary" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"contactAt" DATETIME NOT NULL,
|
||||
"customerId" TEXT,
|
||||
"vendorId" TEXT,
|
||||
"createdById" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "CrmContactEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CrmContactEntry_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CrmContactEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "CrmContactEntry_customerId_contactAt_idx" ON "CrmContactEntry"("customerId", "contactAt");
|
||||
|
||||
CREATE INDEX "CrmContactEntry_vendorId_contactAt_idx" ON "CrmContactEntry"("vendorId", "contactAt");
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "Customer" ADD COLUMN "isReseller" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN "resellerDiscountPercent" REAL NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN "parentCustomerId" TEXT REFERENCES "Customer" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "Customer_parentCustomerId_idx" ON "Customer"("parentCustomerId");
|
||||
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE "Customer" ADD COLUMN "paymentTerms" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
|
||||
ALTER TABLE "Customer" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Customer" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "Vendor" ADD COLUMN "paymentTerms" TEXT;
|
||||
ALTER TABLE "Vendor" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
|
||||
ALTER TABLE "Vendor" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Vendor" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE "CrmContact" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'OTHER',
|
||||
"email" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
|
||||
"customerId" TEXT,
|
||||
"vendorId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "CrmContact_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CrmContact_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "CrmContact_customerId_idx" ON "CrmContact"("customerId");
|
||||
CREATE INDEX "CrmContact_vendorId_idx" ON "CrmContact"("vendorId");
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "Customer" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
|
||||
ALTER TABLE "Customer" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Customer" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Customer" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Customer" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "Vendor" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
|
||||
ALTER TABLE "Vendor" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Vendor" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Vendor" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Vendor" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE "InventoryItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sku" TEXT NOT NULL,
|
||||
"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,
|
||||
"defaultCost" REAL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "InventoryBomLine" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"parentItemId" TEXT NOT NULL,
|
||||
"componentItemId" TEXT NOT NULL,
|
||||
"quantity" REAL NOT NULL,
|
||||
"unitOfMeasure" TEXT NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "InventoryBomLine_parentItemId_fkey" FOREIGN KEY ("parentItemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryBomLine_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
|
||||
CREATE INDEX "InventoryBomLine_parentItemId_position_idx" ON "InventoryBomLine"("parentItemId", "position");
|
||||
CREATE INDEX "InventoryBomLine_componentItemId_idx" ON "InventoryBomLine"("componentItemId");
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE "Warehouse" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "WarehouseLocation" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"warehouseId" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WarehouseLocation_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Warehouse_code_key" ON "Warehouse"("code");
|
||||
CREATE UNIQUE INDEX "WarehouseLocation_warehouseId_code_key" ON "WarehouseLocation"("warehouseId", "code");
|
||||
CREATE INDEX "WarehouseLocation_warehouseId_idx" ON "WarehouseLocation"("warehouseId");
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InventoryTransaction" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"warehouseId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"transactionType" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "InventoryTransaction_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryTransaction_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryTransaction_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryTransaction_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InventoryTransaction_itemId_createdAt_idx" ON "InventoryTransaction"("itemId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InventoryTransaction_warehouseId_createdAt_idx" ON "InventoryTransaction"("warehouseId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InventoryTransaction_locationId_createdAt_idx" ON "InventoryTransaction"("locationId", "createdAt");
|
||||
@@ -0,0 +1,70 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SalesQuote" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"documentNumber" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"issueDate" DATETIME NOT NULL,
|
||||
"expiresAt" DATETIME,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SalesQuote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SalesQuoteLine" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"quoteId" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitOfMeasure" TEXT NOT NULL,
|
||||
"unitPrice" REAL NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SalesQuoteLine_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "SalesQuote" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "SalesQuoteLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SalesOrder" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"documentNumber" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"issueDate" DATETIME NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SalesOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SalesOrderLine" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitOfMeasure" TEXT NOT NULL,
|
||||
"unitPrice" REAL NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SalesOrderLine_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "SalesOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SalesQuote_documentNumber_key" ON "SalesQuote"("documentNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SalesQuoteLine_quoteId_position_idx" ON "SalesQuoteLine"("quoteId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SalesOrder_documentNumber_key" ON "SalesOrder"("documentNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SalesOrderLine_orderId_position_idx" ON "SalesOrderLine"("orderId", "position");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "InventoryItem" ADD COLUMN "defaultPrice" REAL;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "SalesQuote" ADD COLUMN "discountPercent" REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "SalesQuote" ADD COLUMN "taxPercent" REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "SalesQuote" ADD COLUMN "freightAmount" REAL NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE "SalesOrder" ADD COLUMN "discountPercent" REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "SalesOrder" ADD COLUMN "taxPercent" REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "SalesOrder" ADD COLUMN "freightAmount" REAL NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "Shipment" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"shipmentNumber" TEXT NOT NULL,
|
||||
"salesOrderId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"shipDate" DATETIME,
|
||||
"carrier" TEXT NOT NULL,
|
||||
"serviceLevel" TEXT NOT NULL,
|
||||
"trackingNumber" TEXT NOT NULL,
|
||||
"packageCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Shipment_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Shipment_shipmentNumber_key" ON "Shipment"("shipmentNumber");
|
||||
CREATE INDEX "Shipment_salesOrderId_createdAt_idx" ON "Shipment"("salesOrderId", "createdAt");
|
||||
@@ -0,0 +1,36 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseOrder" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"documentNumber" TEXT NOT NULL,
|
||||
"vendorId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"issueDate" DATETIME NOT NULL,
|
||||
"taxPercent" REAL NOT NULL DEFAULT 0,
|
||||
"freightAmount" REAL NOT NULL DEFAULT 0,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "PurchaseOrder_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseOrderLine" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"purchaseOrderId" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitOfMeasure" TEXT NOT NULL,
|
||||
"unitCost" REAL NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "PurchaseOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PurchaseOrder_documentNumber_key" ON "PurchaseOrder"("documentNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_position_idx" ON "PurchaseOrderLine"("purchaseOrderId", "position");
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE "PurchaseReceipt" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"receiptNumber" TEXT NOT NULL,
|
||||
"purchaseOrderId" TEXT NOT NULL,
|
||||
"warehouseId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"receivedAt" DATETIME NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "PurchaseReceipt_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "PurchaseReceipt_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "PurchaseReceipt_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "PurchaseReceipt_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "PurchaseReceiptLine" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"purchaseReceiptId" TEXT NOT NULL,
|
||||
"purchaseOrderLineId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "PurchaseReceiptLine_purchaseReceiptId_fkey" FOREIGN KEY ("purchaseReceiptId") REFERENCES "PurchaseReceipt" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "PurchaseReceiptLine_purchaseOrderLineId_fkey" FOREIGN KEY ("purchaseOrderLineId") REFERENCES "PurchaseOrderLine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PurchaseReceipt_receiptNumber_key" ON "PurchaseReceipt"("receiptNumber");
|
||||
CREATE INDEX "PurchaseReceipt_purchaseOrderId_createdAt_idx" ON "PurchaseReceipt"("purchaseOrderId", "createdAt");
|
||||
CREATE INDEX "PurchaseReceipt_warehouseId_createdAt_idx" ON "PurchaseReceipt"("warehouseId", "createdAt");
|
||||
CREATE INDEX "PurchaseReceipt_locationId_createdAt_idx" ON "PurchaseReceipt"("locationId", "createdAt");
|
||||
CREATE INDEX "PurchaseReceiptLine_purchaseReceiptId_idx" ON "PurchaseReceiptLine"("purchaseReceiptId");
|
||||
CREATE INDEX "PurchaseReceiptLine_purchaseOrderLineId_idx" ON "PurchaseReceiptLine"("purchaseOrderLineId");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user