Compare commits
28 Commits
f85563ce99
...
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 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -6,6 +6,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Reverse project visibility on quote and sales-order detail pages, purchase-order header project linkage, sales-order-driven project auto-derivation for new work orders and purchase orders, quote-to-sales-order project carry-through during conversion, and migration backfill for existing work orders and purchase orders linked through project sales orders
|
||||||
- Finance module with customer-payment posting against sales orders, finance costing assumptions, sales-order cash/spend ledger rollups, manufacturing cost snapshots, and CapEx tracking for equipment, tooling, and consumables
|
- 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
|
- 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
|
- 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
|
||||||
@@ -19,7 +20,33 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
- 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 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 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
|
- Workbench station cards now show planned-vs-actual load so planners can compare schedule intent against recorded execution time
|
||||||
|
- Work-order `On Hold` quick status changes now require a recorded hold reason and persist the active blocker on the work-order record and audit trail
|
||||||
|
- Project milestone cards now support inline quick status actions for start, block, complete, reset, and reopen flows directly from the project detail view
|
||||||
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
|
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
|
||||||
|
- UI density standardization pass across app shell, dashboard, finance, project detail, manufacturing detail, and admin surfaces, including tighter panel spacing, more compact shell/navigation spacing, and removal of redundant explanatory subcopy in favor of concise uppercase section labels
|
||||||
|
- Continued density standardization across CRM, inventory, sales, purchasing, and shipping list/detail surfaces so module headers, filter bars, and status panels follow the same tighter uppercase operational pattern
|
||||||
|
- Continued density standardization across CRM, sales, purchasing, shipping, manufacturing, and project form/detail headers so editor and record surfaces now follow the same compact uppercase pattern with less redundant helper copy
|
||||||
|
- Continued density standardization across CRM detail internals and inventory item editing so secondary cards, timeline/history panels, thumbnail panels, BOM/routing editors, and empty states use the tighter shared surface treatment with less filler copy
|
||||||
|
- Continued density standardization across inventory detail transaction/transfer/reservation surfaces, and fixed item-editor navigation controls so SKU master and cancel actions navigate reliably from the create-item form
|
||||||
|
- Continued density standardization across sales, purchasing, shipping, and manufacturing editor internals, and standardized form-header cancel actions onto button-driven navigation to avoid in-form route-transition edge cases
|
||||||
|
- Continued density standardization across sales, purchasing, shipping, and manufacturing detail internals, including denser KPI strips, tighter side panels, shorter empty states, and less redundant context copy on high-traffic record views
|
||||||
|
- Continued density standardization across shared attachment and revision-comparison surfaces, and changed inventory item-editor exit actions to hard navigation so SKU master and cancel transitions no longer depend on client-side router state
|
||||||
|
- Continued density standardization across the SKU master builder and planning workbench, including tighter tree and board panels, denser exception and focus surfaces, shorter empty states, and less helper copy on those operational screens
|
||||||
|
- Continued density standardization across warehouse list/detail/editor screens and the manufacturing station surface, including tighter status blocks, denser location/station cards, and removal of older roomy header patterns
|
||||||
|
- Continued density standardization across company settings and deeper manufacturing detail surfaces, including tighter admin/profile/theme sections, denser work-order execution panels, and compact issue/completion history cards
|
||||||
|
- Continued density standardization across project cockpit/detail internals, including tighter cockpit cards, denser purchasing and readiness panels, and compact milestone, manufacturing-link, and activity-timeline surfaces
|
||||||
|
- Continued density standardization across admin diagnostics, user management, and CRM contacts, including tighter filter/forms, denser summary cards, and compact contact/account management surfaces
|
||||||
|
- Workbench usability pass with sticky planner controls, stronger selected-row and selected-day state, clearer heatmap/day context, and more explicit dispatch-oriented action affordances
|
||||||
|
- Workbench usability depth with keyboard row navigation, enter-to-open behavior, escape-to-clear, and inline readiness/shortage/hold signal pills across planner rows and day-detail cards
|
||||||
|
- Workbench dispatch workflow depth with saved planner views, a release queue for visible ready work, queued-record visibility in the sticky control bar, and batch release directly from the workbench
|
||||||
|
- Workbench batch operation rebalance with multi-operation selection, sticky-bar batch reschedule controls, station reassignment across selected operations, and selected-operation visibility in row signals and focus context
|
||||||
|
- Workbench conflict-intelligence pass with projected batch target load, overload warnings before batch station moves, and best-alternate-station suggestions inside the sticky rebalance controls
|
||||||
|
- Workbench date-aware slot guidance using station working-day calendars and queue settings to suggest the next workable batch landing dates directly from the sticky rebalance controls
|
||||||
|
- Planning timeline now includes station day-load rollups, and Workbench slot suggestions use that server-backed per-day capacity data instead of only summary-level utilization heuristics
|
||||||
|
- Workbench now surfaces day-level capacity directly in the planner, including hot-station day counts on heatmap cells, selected-day station load breakdowns, and per-station hot-day chips in station grouping mode
|
||||||
|
- Workbench exception prioritization now scores and ranks projects, work orders, agenda rows, and dispatch exceptions by lateness, blockage, shortage, readiness, and overload pressure, with inline priority chips for faster triage
|
||||||
|
- Workbench now surfaces top-priority action lanes for `DO NOW`, `UNBLOCK`, and `RELEASE READY` records so planners can jump straight into ranked dispatch queues before working deeper lists
|
||||||
|
- Workbench action lanes now support direct follow-through from the lane cards themselves, including queue-release and the first inline build/buy/open actions without requiring a second step into the focus drawer
|
||||||
- Project-side milestone and work-order rollups surfaced on project list and detail pages
|
- Project-side milestone and work-order rollups surfaced on project list and detail pages
|
||||||
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||||
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
|
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
|
||||||
@@ -76,6 +103,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Shipping-label PDFs now render inside an explicit single-page 4x6 canvas with tighter print-safe spacing and overflow-safe text wrapping to prevent second-sheet runover on label printers
|
||||||
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
|
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
|
||||||
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
|
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
|
||||||
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
|
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
|
||||||
|
|||||||
@@ -55,11 +55,13 @@ This repository implements the platform foundation milestone:
|
|||||||
6. Any non-filter UI that looks up records or items must use a searchable picker/autocomplete, not a long static dropdown.
|
6. Any non-filter UI that looks up records or items must use a searchable picker/autocomplete, not a long static dropdown.
|
||||||
7. Inventory items must carry both `defaultCost` and `defaultPrice`; sales documents should default line pricing from the selected item `defaultPrice`.
|
7. Inventory items must carry both `defaultCost` and `defaultPrice`; sales documents should default line pricing from the selected item `defaultPrice`.
|
||||||
8. Maintain the denser UI baseline on active screens; avoid reintroducing oversized `px-4 py-3` style controls, tall action bars, or overly loose card spacing without a specific reason.
|
8. Maintain the denser UI baseline on active screens; avoid reintroducing oversized `px-4 py-3` style controls, tall action bars, or overly loose card spacing without a specific reason.
|
||||||
9. Treat the landing page as `Dashboard`: a metric-oriented, modular command surface that should accumulate reusable operational panels over time.
|
9. Prefer concise uppercase module and section labels in the live interface, and avoid redundant descriptive subcopy when the surrounding data already makes the purpose clear.
|
||||||
10. Purchase-order item selection must be restricted to inventory items where `isPurchasable = true`.
|
10. When designing operational pages, bias toward information density: tighter panel padding, smaller stack gaps, and fewer explanatory filler blocks.
|
||||||
11. Treat `Projects` as a first-class cross-module domain tying together CRM, sales, inventory, purchasing, shipping, and planning; do not bury it as a one-off manufacturing subfeature.
|
11. Treat the landing page as `Dashboard`: a metric-oriented, modular command surface that should accumulate reusable operational panels over time.
|
||||||
12. Keep `Projects`, `Manufacturing`, and `Planning` distinct: projects are long-running program records, manufacturing is execution, and planning is scheduling/visibility.
|
12. Purchase-order item selection must be restricted to inventory items where `isPurchasable = true`.
|
||||||
13. New top-level modules added to the app shell should include a matching SVG icon in navigation so the module list remains visually scannable.
|
13. Treat `Projects` as a first-class cross-module domain tying together CRM, sales, inventory, purchasing, shipping, and planning; do not bury it as a one-off manufacturing subfeature.
|
||||||
|
14. Keep `Projects`, `Manufacturing`, and `Planning` distinct: projects are long-running program records, manufacturing is execution, and planning is scheduling/visibility.
|
||||||
|
15. New top-level modules added to the app shell should include a matching SVG icon in navigation so the module list remains visually scannable.
|
||||||
|
|
||||||
## Operational notes
|
## Operational notes
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -27,8 +27,8 @@ Current foundation scope includes:
|
|||||||
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
||||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||||
- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments
|
- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments
|
||||||
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments
|
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, inline milestone quick-status actions, notes, attachments, reverse-linked quote/sales-order visibility, and downstream project-context carry-through into generated work orders and purchase orders
|
||||||
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, required hold reasons for `On Hold` status changes, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
||||||
- planning 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
|
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
|
||||||
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||||
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
||||||
@@ -89,6 +89,8 @@ Navigation direction:
|
|||||||
- module navigation now uses inline SVG icons alongside labels
|
- module navigation now uses inline SVG icons alongside labels
|
||||||
- new modules should add a clear, domain-appropriate SVG icon when they are added to the shell
|
- new modules should add a clear, domain-appropriate SVG icon when they are added to the shell
|
||||||
- icons should stay lightweight, theme-aware, and dependency-free unless there is a strong reason to introduce a shared icon package
|
- icons should stay lightweight, theme-aware, and dependency-free unless there is a strong reason to introduce a shared icon package
|
||||||
|
- active operational screens should default to a denser layout baseline with tighter card padding, smaller inter-panel gaps, and less decorative negative space
|
||||||
|
- module headers and section labels should prefer uppercase naming and concise operational wording instead of redundant explanatory subcopy inside the working interface
|
||||||
|
|
||||||
## Finance Direction
|
## Finance Direction
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ Current interactions:
|
|||||||
|
|
||||||
- CRM: each project should link to a customer account and relevant contacts
|
- CRM: each project should link to a customer account and relevant contacts
|
||||||
- Sales: quotes and sales orders can already attach to projects
|
- Sales: quotes and sales orders can already attach to projects
|
||||||
|
- Sales workflow now also exposes the reverse project link on quote and sales-order detail pages, and quote conversion carries linked project context forward into the created sales order
|
||||||
- Shipping: shipments tied to project deliverables are visible from the project record
|
- Shipping: shipments tied to project deliverables are visible from the project record
|
||||||
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets
|
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets
|
||||||
- Detail/List UX: projects now surface milestone progress and linked execution rollups
|
- Detail/List UX: projects now surface milestone progress and linked execution rollups
|
||||||
@@ -123,8 +126,8 @@ Current interactions:
|
|||||||
Next expansion areas:
|
Next expansion areas:
|
||||||
|
|
||||||
- Inventory: projects should reference item/BOM scope and later expose shortages or allocations
|
- Inventory: projects should reference item/BOM scope and later expose shortages or allocations
|
||||||
- Purchasing: project material demand is now visible through linked PO, receipt, vendor, and outstanding-supply rollups, and should later expand into project-side purchasing actions
|
- Purchasing: project material demand is now visible through linked PO, receipt, vendor, and outstanding-supply rollups, and purchase orders now persist header-level project context derived from linked sales demand or explicit project selection
|
||||||
- Manufacturing: work orders should link back to projects without turning projects into the manufacturing module
|
- Manufacturing: work orders already auto-link back to the project when the originating sales order belongs to a project, without turning projects into the manufacturing module
|
||||||
- Planning: project milestones and execution dates should feed workbench scheduling, dependency views, and richer planner drilldowns
|
- Planning: project milestones and execution dates should feed workbench scheduling, dependency views, and richer planner drilldowns
|
||||||
|
|
||||||
## Manufacturing Direction
|
## Manufacturing Direction
|
||||||
|
|||||||
21
ROADMAP.md
21
ROADMAP.md
@@ -14,10 +14,11 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
## Near-term priority order
|
## Near-term priority order
|
||||||
|
|
||||||
1. Deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
|
1. Manufacturing costing and execution depth, including scrap/rework/yield tracking and variance visibility
|
||||||
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
2. Finance expansion across AP disbursements, invoice matching, vendor payments, and project-level P&L
|
||||||
3. Dashboard KPI, alert, recent-activity, and exception-widget expansion
|
3. Workbench finite-capacity intelligence, including conflict handling, queue-slot guidance, and auto-rebalance recommendations
|
||||||
4. Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
4. Dashboard KPI, alert, recent-activity, and exception-widget expansion, especially for finance, manufacturing, and planning
|
||||||
|
5. Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
||||||
|
|
||||||
## Active roadmap
|
## Active roadmap
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
- Expand `Dashboard` by modular panels rather than redesigning it into a different shell
|
- Expand `Dashboard` by modular panels rather than redesigning it into a different shell
|
||||||
- Add richer KPI widgets, alerts, recent-activity queues, and exception reporting
|
- Add richer KPI widgets, alerts, recent-activity queues, and exception reporting
|
||||||
- Add deeper project, manufacturing, purchasing, shipping, and audit/system-health widgets
|
- Add deeper project, manufacturing, purchasing, finance, shipping, and audit/system-health widgets
|
||||||
|
|
||||||
### CRM and master data
|
### CRM and master data
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
- Expand from customer receipts into AP disbursements, invoice matching, and vendor-payment control
|
- 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
|
- 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
|
- 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 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
|
- Add richer dashboard widgets for margin pressure, open receivables, CapEx exposure, and payment coverage risk
|
||||||
|
|
||||||
@@ -84,11 +86,11 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
- Project document hub for drawings, support files, correspondence, and revision references
|
- Project document hub for drawings, support files, correspondence, and revision references
|
||||||
- Non-manufacturing work packages for long-running execution tracking
|
- Non-manufacturing work packages for long-running execution tracking
|
||||||
- Deeper project-level cost, material, schedule, and delivery rollups beyond the current purchasing/readiness cockpit
|
- Deeper project-level cost, material, schedule, delivery, and finance rollups beyond the current purchasing/readiness cockpit
|
||||||
- Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication
|
- Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication
|
||||||
- Project templates for repeatable build types
|
- Project templates for repeatable build types
|
||||||
- Project-specific attachment bundles and revision snapshots
|
- Project-specific attachment bundles and revision snapshots
|
||||||
- One-screen project cockpit with deeper cost, material, schedule, shipping, and action-oriented summary workflows
|
- One-screen project cockpit with deeper cost, material, schedule, shipping, finance, and action-oriented summary workflows
|
||||||
- Better cross-links between project, customer, order, shipment, and inventory records
|
- Better cross-links between project, customer, order, shipment, and inventory records
|
||||||
- Project filtering by customer, owner, status, due date, and risk
|
- Project filtering by customer, owner, status, due date, and risk
|
||||||
- Project activity timeline and audit-friendly milestone history
|
- Project activity timeline and audit-friendly milestone history
|
||||||
@@ -99,6 +101,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
- Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
|
- Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
|
||||||
- Material consumption depth, WIP tracking, and execution traceability
|
- Material consumption depth, WIP tracking, and execution traceability
|
||||||
- Deeper labor depth beyond the shipped operator assignment and timer-based labor capture, including crew-level staffing, labor approvals, and machine/runtime integration
|
- Deeper labor depth beyond the shipped operator assignment and timer-based labor capture, including crew-level staffing, labor approvals, and machine/runtime integration
|
||||||
|
- Planned-versus-actual material, labor, and overhead variance reporting shared with the finance module
|
||||||
- Manufacturing rollups for open work, blockers, shortages, and throughput
|
- Manufacturing rollups for open work, blockers, shortages, and throughput
|
||||||
- Traveler/job packet output
|
- Traveler/job packet output
|
||||||
- Partial completions and split-order execution visibility
|
- Partial completions and split-order execution visibility
|
||||||
@@ -109,14 +112,16 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
### Planning and scheduling
|
### Planning and scheduling
|
||||||
|
|
||||||
|
- Standardize dense UI primitives and shared page shells so future Workbench, dashboard, and operational screens reuse the same cards, filter bars, empty states, and section wrappers instead of reintroducing ad hoc layout patterns
|
||||||
- Task dependencies, milestones, and progress updates
|
- Task dependencies, milestones, and progress updates
|
||||||
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
|
- 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
|
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
|
||||||
- Theme-compliant workbench scheduling surfaces for light/dark mode
|
|
||||||
- Collapsible schedule groupings and saved planner views
|
- Collapsible schedule groupings and saved planner views
|
||||||
- Richer conflict handling, queue-slot suggestions, and auto-rebalance logic beyond the shipped station-lane drag scheduling
|
- Richer conflict handling, queue-slot suggestions, and auto-rebalance logic beyond the shipped station-lane drag scheduling
|
||||||
|
- Best-next-slot and best-alternate-station recommendations for planners handling overload and blockers
|
||||||
- Critical-path and overdue highlighting
|
- Critical-path and overdue highlighting
|
||||||
- Richer finite-capacity warnings, automated rebalance logic, and station drag-rescheduling beyond the shipped overload indicators and workbench rebalance controls
|
- Richer finite-capacity warnings, automated rebalance logic, and station drag-rescheduling beyond the shipped overload indicators and workbench rebalance controls
|
||||||
|
- Deeper material readiness, pegged-supply, and dispatch recommendation visibility inside Workbench rows and focus panels
|
||||||
- Better mobile and tablet behavior for shop-floor lookups
|
- Better mobile and tablet behavior for shop-floor lookups
|
||||||
- Faster filtering by project, customer, work center, and status
|
- Faster filtering by project, customer, work center, and status
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
|
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
|
||||||
- Logistics attachments directly on shipment records
|
- Logistics attachments directly on shipment records
|
||||||
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||||
- Project milestones and project-side milestone/work-order rollups
|
- Reverse project linkage visibility on quote and sales-order detail pages, plus project-context carry-through into generated work orders and purchase orders with sales-order-driven backfill for existing records
|
||||||
|
- Project 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 cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
||||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||||
- Manufacturing foundation with work orders, project linkage, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, and work-order attachments
|
- Manufacturing foundation with work orders, project linkage, operation execution controls, operator assignment, timer-based and manual labor posting, required hold reasons for `On Hold` status changes, material issue posting, completion posting, and work-order attachments
|
||||||
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
||||||
- Vendor invoice/supporting-document attachments directly on purchase orders
|
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||||
@@ -78,6 +79,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Project records with customer linkage, status, owner, priority, due dates, milestones, and notes
|
- Project records with customer linkage, status, owner, priority, due dates, milestones, and notes
|
||||||
- Project milestone status tracking and project-side milestone/work-order rollups
|
- Project milestone status tracking and project-side milestone/work-order rollups
|
||||||
- Project-to-quote, sales-order, and shipment linkage for delivery context
|
- Project-to-quote, sales-order, and shipment linkage for delivery context
|
||||||
|
- Quote-to-sales-order project carry-through plus reverse-linked project visibility from the sales workflow
|
||||||
- Project attachments through the shared file pipeline
|
- Project attachments through the shared file pipeline
|
||||||
- Project list/detail/create/edit flows and dashboard visibility
|
- Project list/detail/create/edit flows and dashboard visibility
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Netting against available stock, active reservations, open work orders, and open purchase orders
|
- Netting against available stock, active reservations, open work orders, and open purchase orders
|
||||||
- Build and buy recommendations surfaced directly from the sales-order workflow
|
- Build and buy recommendations surfaced directly from the sales-order workflow
|
||||||
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
|
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
|
||||||
|
- Generated work orders and purchase orders now auto-carry linked project context when demand traces back to a project-linked sales order
|
||||||
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
|
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
|
||||||
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
|
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
|
||||||
- Pegged work-order and purchase-order supply links back to originating sales demand
|
- Pegged work-order and purchase-order supply links back to originating sales demand
|
||||||
|
|||||||
@@ -200,19 +200,19 @@ export function AppShell() {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen px-4 py-5 xl:px-6 2xl:px-8">
|
<div className="min-h-screen px-3 py-3 xl:px-4 2xl:px-5">
|
||||||
<div className="mx-auto flex w-full max-w-[1760px] gap-3 2xl:gap-4">
|
<div className="mx-auto flex w-full max-w-[1760px] gap-2.5 2xl:gap-3">
|
||||||
<aside className="hidden w-72 shrink-0 flex-col rounded-[22px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur md:flex 2xl:w-80">
|
<aside className="hidden w-72 shrink-0 flex-col rounded-[20px] border border-line/70 bg-surface/90 p-3 shadow-panel backdrop-blur md:flex 2xl:w-80">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1>
|
<h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mt-6 space-y-2">
|
<nav className="mt-4 space-y-1.5">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={link.to}
|
key={link.to}
|
||||||
to={link.to}
|
to={link.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2 rounded-2xl px-2 py-2 text-sm font-semibold transition ${
|
`flex items-center gap-2 rounded-xl px-2.5 py-2 text-[12px] font-semibold uppercase tracking-[0.12em] transition ${
|
||||||
isActive ? "bg-brand text-white" : "text-text hover:bg-page"
|
isActive ? "bg-brand text-white" : "text-text hover:bg-page"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@@ -222,12 +222,12 @@ export function AppShell() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="mt-auto space-y-3">
|
<div className="mt-auto space-y-2.5">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/70 p-3">
|
<div className="rounded-[16px] border border-line/70 bg-page/70 p-2.5">
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">Theme</p>
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">Theme</p>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/70 p-4">
|
<div className="rounded-[16px] border border-line/70 bg-page/70 p-3">
|
||||||
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
|
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
|
||||||
<p className="text-xs text-muted">{user?.email}</p>
|
<p className="text-xs text-muted">{user?.email}</p>
|
||||||
<button
|
<button
|
||||||
@@ -235,7 +235,7 @@ export function AppShell() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void logout();
|
void logout();
|
||||||
}}
|
}}
|
||||||
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
className="mt-3 rounded-xl bg-text px-3 py-2 text-sm font-semibold text-page"
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
@@ -243,13 +243,13 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="min-w-0 flex-1">
|
<main className="min-w-0 flex-1">
|
||||||
<nav className="mb-4 flex gap-3 overflow-x-auto rounded-[20px] border border-line/70 bg-surface/85 p-3 shadow-panel backdrop-blur md:hidden">
|
<nav className="mb-3 flex gap-2 overflow-x-auto rounded-[18px] border border-line/70 bg-surface/85 p-2.5 shadow-panel backdrop-blur md:hidden">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={link.to}
|
key={link.to}
|
||||||
to={link.to}
|
to={link.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`inline-flex whitespace-nowrap items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition ${
|
`inline-flex whitespace-nowrap items-center gap-2 rounded-xl px-3 py-2 text-[12px] font-semibold uppercase tracking-[0.12em] transition ${
|
||||||
isActive ? "bg-brand text-white" : "bg-page/70 text-text"
|
isActive ? "bg-brand text-white" : "bg-page/70 text-text"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@@ -259,7 +259,7 @@ export function AppShell() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="mb-4 md:hidden">
|
<div className="mb-3 md:hidden">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ interface ConfirmActionDialogProps {
|
|||||||
intent?: "danger" | "primary";
|
intent?: "danger" | "primary";
|
||||||
confirmationLabel?: string;
|
confirmationLabel?: string;
|
||||||
confirmationValue?: string;
|
confirmationValue?: string;
|
||||||
|
extraFieldLabel?: string;
|
||||||
|
extraFieldPlaceholder?: string;
|
||||||
|
extraFieldValue?: string;
|
||||||
|
extraFieldRequired?: boolean;
|
||||||
|
extraFieldMultiline?: boolean;
|
||||||
|
onExtraFieldChange?: (value: string) => void;
|
||||||
isConfirming?: boolean;
|
isConfirming?: boolean;
|
||||||
onConfirm: () => void | Promise<void>;
|
onConfirm: () => void | Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -27,6 +33,12 @@ export function ConfirmActionDialog({
|
|||||||
intent = "danger",
|
intent = "danger",
|
||||||
confirmationLabel,
|
confirmationLabel,
|
||||||
confirmationValue,
|
confirmationValue,
|
||||||
|
extraFieldLabel,
|
||||||
|
extraFieldPlaceholder,
|
||||||
|
extraFieldValue = "",
|
||||||
|
extraFieldRequired = false,
|
||||||
|
extraFieldMultiline = false,
|
||||||
|
onExtraFieldChange,
|
||||||
isConfirming = false,
|
isConfirming = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -44,7 +56,11 @@ export function ConfirmActionDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
||||||
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
|
const requiresExtraField = Boolean(extraFieldLabel);
|
||||||
|
const isConfirmDisabled =
|
||||||
|
isConfirming ||
|
||||||
|
(requiresTypedConfirmation && typedValue.trim() !== confirmationValue) ||
|
||||||
|
(requiresExtraField && extraFieldRequired && extraFieldValue.trim().length === 0);
|
||||||
const confirmButtonClass =
|
const confirmButtonClass =
|
||||||
intent === "danger"
|
intent === "danger"
|
||||||
? "bg-red-600 text-white hover:bg-red-700"
|
? "bg-red-600 text-white hover:bg-red-700"
|
||||||
@@ -81,6 +97,27 @@ export function ConfirmActionDialog({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
{requiresExtraField ? (
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">{extraFieldLabel}</span>
|
||||||
|
{extraFieldMultiline ? (
|
||||||
|
<textarea
|
||||||
|
value={extraFieldValue}
|
||||||
|
onChange={(event) => onExtraFieldChange?.(event.target.value)}
|
||||||
|
placeholder={extraFieldPlaceholder}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-[18px] border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={extraFieldValue}
|
||||||
|
onChange={(event) => onExtraFieldChange?.(event.target.value)}
|
||||||
|
placeholder={extraFieldPlaceholder}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): A
|
|||||||
|
|
||||||
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
|
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
|
||||||
return (
|
return (
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
@@ -111,7 +111,7 @@ function ComparisonCard({ label, document }: { label: string; document: Comparis
|
|||||||
{document.status}
|
{document.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
|
<dl className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
{document.metaFields.map((field) => (
|
{document.metaFields.map((field) => (
|
||||||
<div key={`${label}-${field.label}`}>
|
<div key={`${label}-${field.label}`}>
|
||||||
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
|
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
|
||||||
@@ -119,15 +119,15 @@ function ComparisonCard({ label, document }: { label: string; document: Comparis
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
{document.totalFields.map((field) => (
|
{document.totalFields.map((field) => (
|
||||||
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
|
||||||
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
|
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-3">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
|
||||||
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
|
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,11 +164,11 @@ export function DocumentRevisionComparison({
|
|||||||
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
|
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{title}</p>
|
<p className="section-kicker">{title}</p>
|
||||||
<p className="mt-2 text-sm text-muted">{description}</p>
|
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block min-w-[220px]">
|
<label className="block min-w-[220px]">
|
||||||
@@ -202,19 +202,19 @@ export function DocumentRevisionComparison({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
<ComparisonCard label="Baseline" document={leftDocument} />
|
<ComparisonCard label="Baseline" document={leftDocument} />
|
||||||
<ComparisonCard label="Compare To" document={rightDocument} />
|
<ComparisonCard label="Compare To" document={rightDocument} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
|
||||||
{metaChanges.length === 0 && totalChanges.length === 0 ? (
|
{metaChanges.length === 0 && totalChanges.length === 0 ? (
|
||||||
<div className="mt-4 text-sm text-muted">No header or total changes between the selected revisions.</div>
|
<div className="mt-3 text-sm text-muted">No header or total changes.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{[...metaChanges, ...totalChanges].map((change) => (
|
{[...metaChanges, ...totalChanges].map((change) => (
|
||||||
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
|
||||||
<div className="mt-2 text-sm text-text">
|
<div className="mt-2 text-sm text-text">
|
||||||
{change.leftValue} {"->"} {change.rightValue}
|
{change.leftValue} {"->"} {change.rightValue}
|
||||||
@@ -224,28 +224,28 @@ export function DocumentRevisionComparison({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
|
||||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
|
||||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
|
||||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{diffRows.length === 0 ? (
|
{diffRows.length === 0 ? (
|
||||||
<div className="mt-4 text-sm text-muted">No line-level changes between the selected revisions.</div>
|
<div className="mt-3 text-sm text-muted">No line-level changes.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{diffRows.map((row) => (
|
{diffRows.map((row) => (
|
||||||
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-2 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div>
|
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div>
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
|
||||||
|
|||||||
@@ -133,12 +133,12 @@ export function FileAttachmentsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel min-w-0">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
|
<p className="section-kicker">{eyebrow}</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">{title}</h4>
|
<h4 className="text-lg font-bold text-text">{title}</h4>
|
||||||
<p className="mt-2 text-sm text-muted">{description}</p>
|
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
{canWriteFiles ? (
|
{canWriteFiles ? (
|
||||||
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
@@ -147,17 +147,17 @@ export function FileAttachmentsPanel({
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
{!canReadFiles ? (
|
{!canReadFiles ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
You do not have permission to view file attachments.
|
You do not have permission to view file attachments.
|
||||||
</div>
|
</div>
|
||||||
) : attachments.length === 0 ? (
|
) : attachments.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
@@ -166,7 +166,7 @@ export function FileAttachmentsPanel({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
|
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
|
||||||
<p className="mt-1 text-xs text-muted">
|
<p className="mt-1 text-xs text-muted">
|
||||||
{attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()}
|
{attachment.mimeType} - {formatFileSize(attachment.sizeBytes)} - {new Date(attachment.createdAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-3">
|
<div className="flex shrink-0 gap-3">
|
||||||
|
|||||||
@@ -4,6 +4,36 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.page-stack {
|
||||||
|
@apply space-y-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-panel {
|
||||||
|
@apply rounded-[18px] border border-line/70 bg-surface/90 p-3 shadow-panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-panel-tight {
|
||||||
|
@apply rounded-[16px] border border-line/70 bg-page/60 px-3 py-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker {
|
||||||
|
@apply text-[11px] font-semibold uppercase tracking-[0.24em] text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-kicker {
|
||||||
|
@apply text-[11px] font-semibold uppercase tracking-[0.18em] text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-title {
|
||||||
|
@apply mt-1 text-xl font-bold uppercase tracking-[0.08em] text-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-sticky-bar {
|
||||||
|
@apply sticky top-3 z-20 rounded-[18px] border border-line/70 bg-surface/90 p-3 shadow-panel backdrop-blur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--font-family: "Manrope";
|
--font-family: "Manrope";
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import type {
|
|||||||
WorkOrderOperationTimerInput,
|
WorkOrderOperationTimerInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
|
WorkOrderStatusUpdateInput,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
ManufacturingUserOptionDto,
|
ManufacturingUserOptionDto,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
@@ -83,6 +84,7 @@ import type {
|
|||||||
ProjectDetailDto,
|
ProjectDetailDto,
|
||||||
ProjectDocumentOptionDto,
|
ProjectDocumentOptionDto,
|
||||||
ProjectInput,
|
ProjectInput,
|
||||||
|
ProjectMilestoneStatusUpdateInput,
|
||||||
ProjectOwnerOptionDto,
|
ProjectOwnerOptionDto,
|
||||||
ProjectPriority,
|
ProjectPriority,
|
||||||
ProjectShipmentOptionDto,
|
ProjectShipmentOptionDto,
|
||||||
@@ -601,6 +603,13 @@ export const api = {
|
|||||||
updateProject(token: string, projectId: string, payload: ProjectInput) {
|
updateProject(token: string, projectId: string, payload: ProjectInput) {
|
||||||
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
|
updateProjectMilestoneStatus(token: string, projectId: string, milestoneId: string, payload: ProjectMilestoneStatusUpdateInput) {
|
||||||
|
return request<ProjectDetailDto>(
|
||||||
|
`/api/v1/projects/${projectId}/milestones/${milestoneId}/status`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
getProjectCustomerOptions(token: string) {
|
getProjectCustomerOptions(token: string) {
|
||||||
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
|
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
|
||||||
},
|
},
|
||||||
@@ -667,10 +676,10 @@ export const api = {
|
|||||||
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
|
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
|
||||||
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
updateWorkOrderStatus(token: string, workOrderId: string, status: WorkOrderStatus) {
|
updateWorkOrderStatus(token: string, workOrderId: string, payload: WorkOrderStatusUpdateInput) {
|
||||||
return request<WorkOrderDetailDto>(
|
return request<WorkOrderDetailDto>(
|
||||||
`/api/v1/manufacturing/work-orders/${workOrderId}/status`,
|
`/api/v1/manufacturing/work-orders/${workOrderId}/status`,
|
||||||
{ method: "PATCH", body: JSON.stringify({ status }) },
|
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,12 +58,11 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p>
|
<p className="section-kicker">CONTACTS</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">People on this account</h4>
|
<div className="mt-3 space-y-2">
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{contacts.length === 0 ? (
|
{contacts.length === 0 ? (
|
||||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No contacts have been added yet.
|
No contacts have been added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -72,7 +71,7 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-text">
|
<div className="text-sm font-semibold text-text">
|
||||||
{contact.fullName} {contact.isPrimary ? <span className="text-brand">• Primary</span> : null}
|
{contact.fullName} {contact.isPrimary ? <span className="text-brand">- PRIMARY</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,10 +85,10 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
|
<form className="mt-3 space-y-3" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Full name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Full name</span>
|
||||||
<input
|
<input
|
||||||
value={form.fullName}
|
value={form.fullName}
|
||||||
onChange={(event) => updateField("fullName", event.target.value)}
|
onChange={(event) => updateField("fullName", event.target.value)}
|
||||||
@@ -97,7 +96,7 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Role</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Role</span>
|
||||||
<select
|
<select
|
||||||
value={form.role}
|
value={form.role}
|
||||||
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
|
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
|
||||||
@@ -111,7 +110,7 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Email</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
@@ -120,7 +119,7 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Phone</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Phone</span>
|
||||||
<input
|
<input
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={(event) => updateField("phone", event.target.value)}
|
onChange={(event) => updateField("phone", event.target.value)}
|
||||||
@@ -151,4 +150,3 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,21 +111,19 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p>
|
<p className="section-kicker">CRM DETAIL</p>
|
||||||
<h3 className="mt-2 text-2xl font-bold text-text">{record.name}</h3>
|
<h3 className="module-title">{record.name}</h3>
|
||||||
<div className="mt-4">
|
<div className="mt-2.5">
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-2">
|
||||||
<CrmStatusBadge status={record.status} />
|
<CrmStatusBadge status={record.status} />
|
||||||
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
|
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted">
|
<p className="mt-2 text-sm text-muted">UPDATED {new Date(record.updatedAt).toLocaleString()}</p>
|
||||||
{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -146,8 +144,8 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||||
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact</p>
|
<p className="section-kicker">CONTACT</p>
|
||||||
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
|
||||||
@@ -176,8 +174,8 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Internal Notes</p>
|
<p className="section-kicker">INTERNAL NOTES</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">
|
||||||
{record.notes || "No internal notes recorded for this account yet."}
|
{record.notes || "No internal notes recorded for this account yet."}
|
||||||
</p>
|
</p>
|
||||||
@@ -218,31 +216,30 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-2 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Last Contact</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Last Contact</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">
|
<div className="mt-2 text-base font-bold text-text">
|
||||||
{record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"}
|
{record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Entries</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Entries</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.length}</div>
|
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.length}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account Contacts</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account Contacts</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</div>
|
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Attachments</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Attachments</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div>
|
<div className="mt-2 text-base font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
|
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p>
|
<p className="section-kicker">HIERARCHY</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">End customers under this reseller</h4>
|
<div className="mt-3 grid gap-2 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
|
||||||
{record.childCustomers?.map((child) => (
|
{record.childCustomers?.map((child) => (
|
||||||
<Link
|
<Link
|
||||||
key={child.id}
|
key={child.id}
|
||||||
@@ -259,11 +256,10 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{entity === "vendor" ? (
|
{entity === "vendor" ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Activity</p>
|
<p className="section-kicker">PURCHASING ACTIVITY</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
@@ -277,15 +273,15 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{relatedPurchaseOrders.length === 0 ? (
|
{relatedPurchaseOrders.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase orders exist for this vendor yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No purchase orders yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{relatedPurchaseOrders.slice(0, 8).map((order) => (
|
{relatedPurchaseOrders.slice(0, 8).map((order) => (
|
||||||
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-text">{order.documentNumber}</div>
|
<div className="font-semibold text-text">{order.documentNumber}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} · {order.lineCount} lines</div>
|
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} - {order.lineCount} lines</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
|
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,13 +315,9 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
/>
|
/>
|
||||||
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
|
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact History</p>
|
<p className="section-kicker">CONTACT HISTORY</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Add timeline entry</h4>
|
<div className="mt-3">
|
||||||
<p className="mt-2 text-sm text-muted">
|
|
||||||
Record calls, emails, meetings, and follow-up notes directly against this account.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<CrmContactEntryForm
|
<CrmContactEntryForm
|
||||||
form={contactEntryForm}
|
form={contactEntryForm}
|
||||||
isSaving={isSavingContactEntry}
|
isSaving={isSavingContactEntry}
|
||||||
@@ -336,15 +328,14 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timeline</p>
|
<p className="section-kicker">TIMELINE</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Recent interactions</h4>
|
|
||||||
{record.contactHistory.length === 0 ? (
|
{record.contactHistory.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No contact history has been recorded for this account yet.
|
No contact history recorded yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{record.contactHistory.map((entry) => (
|
{record.contactHistory.map((entry) => (
|
||||||
<article key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
|||||||
@@ -110,17 +110,14 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Editor</p>
|
<p className="section-kicker">CRM EDITOR</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">
|
<h3 className="module-title">
|
||||||
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
|
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">
|
|
||||||
Capture the operational contact and address details needed for quoting, purchasing, and shipping workflows.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
|
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
|
||||||
@@ -130,9 +127,9 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="space-y-3 surface-panel">
|
||||||
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
|
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
|
||||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2.5 rounded-[16px] border border-line/70 bg-page/70 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -55,14 +55,11 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
|||||||
}, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
|
}, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
|
<p className="section-kicker">CRM</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3>
|
<h3 className="module-title">{config.collectionLabel}</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">
|
|
||||||
Operational contact records, shipping addresses, and account context for active {config.collectionLabel.toLowerCase()}.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -73,7 +70,7 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr_0.8fr_0.9fr_0.9fr]">
|
<div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr_0.8fr_0.9fr_0.9fr]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
@@ -137,13 +134,13 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
|
||||||
{records.length === 0 ? (
|
{records.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
|
||||||
{config.emptyMessage}
|
{config.emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -82,19 +82,16 @@ function StackedBar({
|
|||||||
|
|
||||||
function DashboardCard({
|
function DashboardCard({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
}: {
|
}: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article className={`rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${className}`.trim()}>
|
<article className={`surface-panel ${className}`.trim()}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
|
<p className="section-kicker">{eyebrow}</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
|
|
||||||
{children}
|
{children}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -290,30 +287,30 @@ export function DashboardPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="page-stack">
|
||||||
{error ? <div className="rounded-[18px] border border-amber-400/30 bg-amber-500/12 px-3 py-3 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
|
{error ? <div className="rounded-[16px] border border-amber-400/30 bg-amber-500/12 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
|
||||||
<section className="grid gap-3 xl:grid-cols-6">
|
<section className="grid gap-3 xl:grid-cols-6">
|
||||||
{metricCards.map((card) => (
|
{metricCards.map((card) => (
|
||||||
<article key={card.label} className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article key={card.label} className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{card.label}</p>
|
<p className="metric-kicker">{card.label}</p>
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div>
|
<div className="mt-1.5 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
<div className="mt-1.5 flex items-center gap-2.5">
|
||||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-page/80">
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-page/80">
|
||||||
<div className={`h-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} />
|
<div className={`h-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted">Live</span>
|
<span className="text-xs text-muted">Live</span>
|
||||||
</div>
|
</div>
|
||||||
{card.secondary ? <div className="mt-2 text-xs text-muted">{card.secondary}</div> : null}
|
{card.secondary ? <div className="mt-1.5 text-xs text-muted">{card.secondary}</div> : null}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]">
|
<section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
<DashboardCard eyebrow="Commercial Surface" title="Revenue and document mix">
|
<DashboardCard eyebrow="COMMERCIAL">
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quotes</div>
|
<div className="metric-kicker">Quotes</div>
|
||||||
<div className="mt-2 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div>
|
<div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs text-muted">
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
<span>Draft</span>
|
<span>Draft</span>
|
||||||
<span>{draftQuoteCount}</span>
|
<span>{draftQuoteCount}</span>
|
||||||
@@ -326,10 +323,10 @@ export function DashboardPage() {
|
|||||||
<ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" />
|
<ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Orders</div>
|
<div className="metric-kicker">Orders</div>
|
||||||
<div className="mt-2 text-2xl font-bold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</div>
|
<div className="mt-1.5 text-2xl font-bold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</div>
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted">Issued / approved</div>
|
<div className="text-xs text-muted">Issued / approved</div>
|
||||||
<div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div>
|
<div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div>
|
||||||
@@ -339,14 +336,14 @@ export function DashboardPage() {
|
|||||||
<div className="mt-1 text-lg font-semibold text-text">{orderCount}</div>
|
<div className="mt-1 text-lg font-semibold text-text">{orderCount}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-2.5">
|
||||||
<ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" />
|
<ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
<DashboardCard eyebrow="CRM Footprint" title="Customer and vendor balance">
|
<DashboardCard eyebrow="CRM">
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-3 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted">Active customers</span>
|
<span className="text-muted">Active customers</span>
|
||||||
@@ -356,21 +353,21 @@ export function DashboardPage() {
|
|||||||
<ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" />
|
<ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-2.5 sm:grid-cols-3">
|
||||||
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Customers</div>
|
<div className="metric-kicker">Customers</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{customerCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{customerCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Resellers</div>
|
<div className="metric-kicker">Resellers</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{resellerCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{resellerCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Vendors</div>
|
<div className="metric-kicker">Vendors</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{vendorCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{vendorCount}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted">Strategic accounts</span>
|
<span className="text-muted">Strategic accounts</span>
|
||||||
<span className="font-semibold text-text">{strategicCustomerCount}</span>
|
<span className="font-semibold text-text">{strategicCustomerCount}</span>
|
||||||
@@ -383,11 +380,11 @@ export function DashboardPage() {
|
|||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-3">
|
<section className="grid gap-3 xl:grid-cols-3">
|
||||||
<DashboardCard eyebrow="Inventory and Supply" title="Stock posture">
|
<DashboardCard eyebrow="INVENTORY">
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Item mix</div>
|
<div className="metric-kicker">Item Mix</div>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-2.5 space-y-2.5">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs text-muted">
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
<span>Active items</span>
|
<span>Active items</span>
|
||||||
@@ -417,14 +414,14 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Storage surface</div>
|
<div className="metric-kicker">Storage</div>
|
||||||
<div className="mt-3 grid gap-3">
|
<div className="mt-2.5 grid gap-2.5">
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="surface-panel-tight bg-surface/80">
|
||||||
<div className="text-xs text-muted">Warehouses</div>
|
<div className="text-xs text-muted">Warehouses</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="surface-panel-tight bg-surface/80">
|
||||||
<div className="text-xs text-muted">Locations</div>
|
<div className="text-xs text-muted">Locations</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{locationCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{locationCount}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,10 +429,10 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
<DashboardCard eyebrow="Supply Execution" title="Purchasing and manufacturing flow">
|
<DashboardCard eyebrow="SUPPLY">
|
||||||
<div className="mt-4 rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="mt-3 surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Open workload split</div>
|
<div className="metric-kicker">Open Workload</div>
|
||||||
<div className="mt-3">
|
<div className="mt-2.5">
|
||||||
<StackedBar
|
<StackedBar
|
||||||
segments={[
|
segments={[
|
||||||
{ value: openPurchaseOrderCount, tone: "bg-teal-500" },
|
{ value: openPurchaseOrderCount, tone: "bg-teal-500" },
|
||||||
@@ -445,31 +442,31 @@ export function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="surface-panel-tight bg-surface/80">
|
||||||
<div className="text-xs text-muted">Open PO queue</div>
|
<div className="text-xs text-muted">Open PO queue</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{openPurchaseOrderCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{openPurchaseOrderCount}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div>
|
<div className="mt-1 text-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="surface-panel-tight bg-surface/80">
|
||||||
<div className="text-xs text-muted">Active work orders</div>
|
<div className="text-xs text-muted">Active work orders</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{activeWorkOrderCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{activeWorkOrderCount}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div>
|
<div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="surface-panel-tight bg-surface/80">
|
||||||
<div className="text-xs text-muted">Issued / approved POs</div>
|
<div className="text-xs text-muted">Issued / approved POs</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
<div className="surface-panel-tight bg-surface/80">
|
||||||
<div className="text-xs text-muted">Released WOs</div>
|
<div className="text-xs text-muted">Released WOs</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div>
|
<div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
<DashboardCard eyebrow="Readiness" title="Planning pressure">
|
<DashboardCard eyebrow="READINESS">
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-3 space-y-2.5">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted">Shortage items</span>
|
<span className="text-muted">Shortage items</span>
|
||||||
<span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span>
|
<span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span>
|
||||||
@@ -478,9 +475,9 @@ export function DashboardPage() {
|
|||||||
<ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" />
|
<ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Build vs buy</div>
|
<div className="metric-kicker">Build Vs Buy</div>
|
||||||
<div className="mt-3">
|
<div className="mt-2.5">
|
||||||
<StackedBar
|
<StackedBar
|
||||||
segments={[
|
segments={[
|
||||||
{ value: buildRecommendationCount, tone: "bg-indigo-500" },
|
{ value: buildRecommendationCount, tone: "bg-indigo-500" },
|
||||||
@@ -488,7 +485,7 @@ export function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted">Build recommendations</div>
|
<div className="text-xs text-muted">Build recommendations</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buildRecommendationCount : "No access"}</div>
|
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buildRecommendationCount : "No access"}</div>
|
||||||
@@ -499,7 +496,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs text-muted">Uncovered quantity</div>
|
<div className="text-xs text-muted">Uncovered quantity</div>
|
||||||
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? totalUncoveredQuantity : "No access"}</div>
|
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? totalUncoveredQuantity : "No access"}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -507,11 +504,11 @@ export function DashboardPage() {
|
|||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]">
|
<section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
<DashboardCard eyebrow="Programs" title="Project and shipment execution">
|
<DashboardCard eyebrow="PROGRAMS">
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-2.5 sm:grid-cols-2">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Projects</div>
|
<div className="metric-kicker">Projects</div>
|
||||||
<div className="mt-3">
|
<div className="mt-2.5">
|
||||||
<StackedBar
|
<StackedBar
|
||||||
segments={[
|
segments={[
|
||||||
{ value: activeProjectCount, tone: "bg-violet-500" },
|
{ value: activeProjectCount, tone: "bg-violet-500" },
|
||||||
@@ -520,7 +517,7 @@ export function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
<div className="mt-3 grid gap-2.5 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted">Active</div>
|
<div className="text-xs text-muted">Active</div>
|
||||||
<div className="mt-1 font-semibold text-text">{activeProjectCount}</div>
|
<div className="mt-1 font-semibold text-text">{activeProjectCount}</div>
|
||||||
@@ -535,9 +532,9 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="surface-panel-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipping</div>
|
<div className="metric-kicker">Shipping</div>
|
||||||
<div className="mt-3">
|
<div className="mt-2.5">
|
||||||
<StackedBar
|
<StackedBar
|
||||||
segments={[
|
segments={[
|
||||||
{ value: activeShipmentCount, tone: "bg-brand" },
|
{ value: activeShipmentCount, tone: "bg-brand" },
|
||||||
@@ -546,7 +543,7 @@ export function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
<div className="mt-3 grid gap-2.5 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted">Open</div>
|
<div className="text-xs text-muted">Open</div>
|
||||||
<div className="mt-1 font-semibold text-text">{activeShipmentCount}</div>
|
<div className="mt-1 font-semibold text-text">{activeShipmentCount}</div>
|
||||||
@@ -563,8 +560,8 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
<DashboardCard eyebrow="Operations Mix" title="Cross-module volume">
|
<DashboardCard eyebrow="OPERATIONS">
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-3 space-y-2.5">
|
||||||
{[
|
{[
|
||||||
{ label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" },
|
{ label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" },
|
||||||
{ label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" },
|
{ label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" },
|
||||||
@@ -574,7 +571,7 @@ export function DashboardPage() {
|
|||||||
{ label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" },
|
{ label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" },
|
||||||
{ label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" },
|
{ label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<div key={row.label} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={row.label} className="surface-panel-tight">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted">{row.label}</span>
|
<span className="text-muted">{row.label}</span>
|
||||||
<span className="font-semibold text-text">{row.value}</span>
|
<span className="font-semibold text-text">{row.value}</span>
|
||||||
@@ -585,7 +582,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{snapshot ? <div className="mt-4 text-xs text-muted">Refreshed {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null}
|
{snapshot ? <div className="mt-3 text-xs text-muted">REFRESHED {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null}
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -192,18 +192,15 @@ export function FinancePage() {
|
|||||||
const currencyCode = profile.currencyCode || "USD";
|
const currencyCode = profile.currencyCode || "USD";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Finance</p>
|
<p className="section-kicker">FINANCE</p>
|
||||||
<h2 className="mt-2 text-2xl font-bold text-text">Cash, spend, and CapEx control</h2>
|
<h2 className="module-title">CASH SPEND CAPEX</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
|
||||||
Track customer payments against sales orders, compare them to linked purchasing and manufacturing spend, and manage capital purchases for equipment, tooling, and consumables.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3 text-sm text-muted">
|
<div className="surface-panel-tight text-sm text-muted">
|
||||||
Live snapshot generated {new Date(dashboard.generatedAt).toLocaleString()}
|
SNAPSHOT {new Date(dashboard.generatedAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,22 +214,19 @@ export function FinancePage() {
|
|||||||
{ label: "Mfg Cost", value: formatCurrency(summary.manufacturingTotalCost, currencyCode) },
|
{ label: "Mfg Cost", value: formatCurrency(summary.manufacturingTotalCost, currencyCode) },
|
||||||
{ label: "CapEx Actual", value: formatCurrency(summary.capexActual, currencyCode) },
|
{ label: "CapEx Actual", value: formatCurrency(summary.capexActual, currencyCode) },
|
||||||
].map((card) => (
|
].map((card) => (
|
||||||
<article key={card.label} className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article key={card.label} className="surface-panel-tight bg-surface/90 shadow-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{card.label}</p>
|
<p className="metric-kicker">{card.label}</p>
|
||||||
<div className="mt-2 text-xl font-extrabold text-text">{card.value}</div>
|
<div className="mt-1.5 text-xl font-extrabold text-text">{card.value}</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<p className="section-kicker">SALES ORDER LEDGER</p>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sales Order Ledger</p>
|
|
||||||
<p className="mt-2 text-sm text-muted">Revenue, receipts, purchasing, and manufacturing cost by order.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-3 overflow-x-auto">
|
||||||
<div className="mt-5 overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-line/60 text-sm">
|
<table className="min-w-full divide-y divide-line/60 text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs uppercase tracking-[0.16em] text-muted">
|
<tr className="text-left text-xs uppercase tracking-[0.16em] text-muted">
|
||||||
@@ -290,9 +284,9 @@ export function FinancePage() {
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Costing Assumptions</p>
|
<p className="section-kicker">COSTING ASSUMPTIONS</p>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="mt-3 grid gap-2.5">
|
||||||
<label className="text-sm text-text">
|
<label className="text-sm text-text">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Currency</span>
|
<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" />
|
<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" />
|
||||||
@@ -307,15 +301,15 @@ export function FinancePage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<button type="button" onClick={() => void handleSaveProfile()} disabled={isSavingProfile} className="mt-4 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
<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"}
|
{isSavingProfile ? "Saving..." : "Save assumptions"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Post Payment</p>
|
<p className="section-kicker">POST PAYMENT</p>
|
||||||
<div className="mt-4 grid gap-3">
|
<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">
|
<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) => (
|
{salesOrders.map((order) => (
|
||||||
<option key={order.id} value={order.id}>
|
<option key={order.id} value={order.id}>
|
||||||
@@ -339,7 +333,7 @@ export function FinancePage() {
|
|||||||
<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" />
|
<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>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<button type="button" onClick={() => void handlePostPayment()} disabled={isPostingPayment || !paymentForm.salesOrderId || paymentForm.amount <= 0} className="mt-4 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
<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"}
|
{isPostingPayment ? "Posting..." : "Post payment"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -348,19 +342,16 @@ export function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div><p className="section-kicker">RECENT PAYMENTS</p></div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Payments</p>
|
|
||||||
<p className="mt-2 text-sm text-muted">Posted receipts linked directly to sales orders.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-3 space-y-2.5">
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{payments.length === 0 ? (
|
{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>
|
<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) => (
|
payments.map((payment) => (
|
||||||
<div key={payment.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={payment.id} className="surface-panel-tight">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/sales/orders/${payment.salesOrderId}`} className="font-semibold text-brand hover:underline">
|
<Link to={`/sales/orders/${payment.salesOrderId}`} className="font-semibold text-brand hover:underline">
|
||||||
@@ -381,12 +372,9 @@ export function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div><p className="section-kicker">CAPEX TRACKER</p></div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CapEx Tracker</p>
|
|
||||||
<p className="mt-2 text-sm text-muted">Manage equipment, tooling, and consumable capital plans with optional PO linkage.</p>
|
|
||||||
</div>
|
|
||||||
{editingCapexId ? (
|
{editingCapexId ? (
|
||||||
<button type="button" onClick={resetCapexForm} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={resetCapexForm} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
Clear edit
|
Clear edit
|
||||||
@@ -394,7 +382,7 @@ export function FinancePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 lg:grid-cols-2">
|
<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" />
|
<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">
|
<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>)}
|
{capexCategories.map((category) => <option key={category} value={category}>{category}</option>)}
|
||||||
@@ -421,12 +409,12 @@ export function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<button type="button" onClick={() => void handleSaveCapex()} disabled={isSavingCapex || !capexForm.title.trim()} className="mt-4 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
<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"}
|
{isSavingCapex ? "Saving..." : editingCapexId ? "Update CapEx" : "Create CapEx"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2.5">
|
||||||
{capex.length === 0 ? (
|
{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>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
@@ -450,7 +438,7 @@ export function FinancePage() {
|
|||||||
notes: entry.notes,
|
notes: entry.notes,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="block w-full rounded-[18px] border border-line/70 bg-page/60 p-3 text-left transition hover:bg-page/80"
|
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 className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -473,7 +461,7 @@ export function FinancePage() {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
|
<div className="surface-panel text-sm text-muted">
|
||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -308,18 +308,18 @@ export function InventoryDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Detail</p>
|
<p className="section-kicker">INVENTORY DETAIL</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{item.sku}</h3>
|
<h3 className="module-title">{item.sku}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{item.name}</p>
|
<p className="mt-1 text-sm text-text">{item.name}</p>
|
||||||
<div className="mt-4 flex flex-wrap gap-3">
|
<div className="mt-2.5 flex flex-wrap gap-2">
|
||||||
<InventoryTypeBadge type={item.type} />
|
<InventoryTypeBadge type={item.type} />
|
||||||
<InventoryStatusBadge status={item.status} />
|
<InventoryStatusBadge status={item.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p>
|
<p className="mt-2 text-sm text-muted">UPDATED {new Date(item.updatedAt).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
@@ -334,40 +334,40 @@ export function InventoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="grid gap-3 xl:grid-cols-7">
|
<section className="grid gap-2 xl:grid-cols-7">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div>
|
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reserved</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reserved</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</div>
|
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Available</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Available</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</div>
|
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Stock Locations</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Stock Locations</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
|
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transactions</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transactions</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div>
|
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{item.transfers.length}</div>
|
<div className="mt-2 text-base font-bold text-text">{item.transfers.length}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
|
<div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
|
<p className="section-kicker">ITEM DEFINITION</p>
|
||||||
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
|
||||||
@@ -397,9 +397,9 @@ export function InventoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Thumbnail</p>
|
<p className="section-kicker">THUMBNAIL</p>
|
||||||
<div className="mt-4 overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
|
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
|
||||||
{thumbnailPreviewUrl ? (
|
{thumbnailPreviewUrl ? (
|
||||||
<img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" />
|
<img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -408,19 +408,19 @@ export function InventoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-xs text-muted">
|
<div className="mt-2 text-xs text-muted">
|
||||||
{thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."}
|
{thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock By Location</p>
|
<p className="section-kicker">STOCK BY LOCATION</p>
|
||||||
{item.stockBalances.length === 0 ? (
|
{item.stockBalances.length === 0 ? (
|
||||||
<p className="mt-4 text-sm text-muted">No stock or reservation balances have been posted for this item yet.</p>
|
<p className="mt-3 text-sm text-muted">No stock or reservation balances posted yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{item.stockBalances.map((balance) => (
|
{item.stockBalances.map((balance) => (
|
||||||
<div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
<div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -444,9 +444,9 @@ export function InventoryDetailPage() {
|
|||||||
|
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}>
|
<form className="surface-panel" onSubmit={handleTransactionSubmit}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p>
|
<p className="section-kicker">STOCK TRANSACTIONS</p>
|
||||||
<div className="mt-5 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
|
||||||
@@ -496,14 +496,14 @@ export function InventoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Movements</p>
|
<p className="section-kicker">RECENT MOVEMENTS</p>
|
||||||
{item.recentTransactions.length === 0 ? (
|
{item.recentTransactions.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No stock transactions have been recorded for this item yet.
|
No stock transactions recorded yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{item.recentTransactions.map((transaction) => (
|
{item.recentTransactions.map((transaction) => (
|
||||||
<article key={transaction.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={transaction.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
@@ -535,9 +535,9 @@ export function InventoryDetailPage() {
|
|||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}>
|
<form className="surface-panel" onSubmit={handleTransferSubmit}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p>
|
<p className="section-kicker">INVENTORY TRANSFER</p>
|
||||||
<div className="mt-5 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
@@ -588,9 +588,9 @@ export function InventoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}>
|
<form className="surface-panel" onSubmit={handleReservationSubmit}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p>
|
<p className="section-kicker">MANUAL RESERVATION</p>
|
||||||
<div className="mt-5 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
@@ -629,14 +629,14 @@ export function InventoryDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Reservations</p>
|
<p className="section-kicker">RESERVATIONS</p>
|
||||||
{item.reservations.length === 0 ? (
|
{item.reservations.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No reservations have been recorded for this item.
|
No reservations recorded.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{item.reservations.map((reservation) => (
|
{item.reservations.map((reservation) => (
|
||||||
<article key={reservation.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={reservation.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -655,14 +655,14 @@ export function InventoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Transfers</p>
|
<p className="section-kicker">TRANSFERS</p>
|
||||||
{item.transfers.length === 0 ? (
|
{item.transfers.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No transfers have been recorded for this item.
|
No transfers recorded.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{item.transfers.map((transfer) => (
|
{item.transfers.map((transfer) => (
|
||||||
<article key={transfer.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={transfer.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
} from "@mrp/shared/dist/inventory/types.js";
|
} from "@mrp/shared/dist/inventory/types.js";
|
||||||
import type { ManufacturingStationDto } from "@mrp/shared";
|
import type { ManufacturingStationDto } from "@mrp/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
@@ -444,41 +444,52 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forceNavigate(path: string) {
|
||||||
|
window.location.assign(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSkuMaster() {
|
||||||
|
forceNavigate("/inventory/sku-master");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
forceNavigate(mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Editor</p>
|
<p className="section-kicker">INVENTORY EDITOR</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Item" : "Edit Item"}</h3>
|
<h3 className="module-title">{mode === "create" ? "NEW ITEM" : "EDIT ITEM"}</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">
|
|
||||||
Define item master data and the first revision of the bill of materials for assemblies and manufactured items.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link
|
<button
|
||||||
to="/inventory/sku-master"
|
type="button"
|
||||||
|
onClick={openSkuMaster}
|
||||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||||
>
|
>
|
||||||
SKU master
|
SKU master
|
||||||
</Link>
|
</button>
|
||||||
<Link
|
<button
|
||||||
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
|
type="button"
|
||||||
|
onClick={closeEditor}
|
||||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel space-y-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
|
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
|
||||||
<div className="block 2xl:col-span-2">
|
<div className="block 2xl:col-span-2">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<span className="block text-sm font-semibold text-text">SKU builder</span>
|
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">SKU BUILDER</span>
|
||||||
<Link to="/inventory/sku-master" className="text-xs font-semibold text-brand">
|
<button type="button" onClick={openSkuMaster} className="text-xs font-semibold text-brand">
|
||||||
Manage SKU tree
|
Manage SKU tree
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3">
|
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -593,7 +604,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="text-sm font-semibold text-text">Thumbnail attachment</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Thumbnail attachment</div>
|
||||||
<div className="mt-2 text-sm text-muted">
|
<div className="mt-2 text-sm text-muted">
|
||||||
{pendingThumbnailFile
|
{pendingThumbnailFile
|
||||||
? `${pendingThumbnailFile.name} will upload when you save this item.`
|
? `${pendingThumbnailFile.name} will upload when you save this item.`
|
||||||
@@ -603,9 +614,6 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.`
|
? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.`
|
||||||
: "Attach a product image, render, or reference photo for this item."}
|
: "Attach a product image, render, or reference photo for this item."}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-xs text-muted">
|
|
||||||
Supported by the existing file-attachment system. The thumbnail is stored separately from general item documents so the item editor can treat it as the primary visual.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 xl:grid-cols-4">
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
@@ -654,7 +662,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
<input type="checkbox" checked={form.isSellable} onChange={(event) => updateField("isSellable", event.target.checked)} />
|
<input type="checkbox" checked={form.isSellable} onChange={(event) => updateField("isSellable", event.target.checked)} />
|
||||||
<span className="text-sm font-semibold text-text">Sellable</span>
|
<span className="text-sm font-semibold uppercase tracking-[0.08em] text-text">Sellable</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
<input
|
<input
|
||||||
@@ -662,7 +670,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
checked={form.isPurchasable}
|
checked={form.isPurchasable}
|
||||||
onChange={(event) => updateField("isPurchasable", event.target.checked)}
|
onChange={(event) => updateField("isPurchasable", event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-semibold text-text">Purchasable</span>
|
<span className="text-sm font-semibold uppercase tracking-[0.08em] text-text">Purchasable</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,7 +741,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-muted">
|
<div className="mt-2 text-xs text-muted">
|
||||||
{form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Demand planning uses this vendor when creating buy recommendations."}
|
{form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Used as the default buy source."}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -756,23 +764,22 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
|
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Routing</p>
|
<p className="section-kicker">MANUFACTURING ROUTING</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Station and time template</h4>
|
<h4 className="text-lg font-bold text-text">STATION AND TIME TEMPLATE</h4>
|
||||||
<p className="mt-2 text-sm text-muted">These operations are copied automatically into work orders and feed the planning workbench without manual planner task entry.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={addOperation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={addOperation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Add operation
|
Add operation
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{form.operations.length === 0 ? (
|
{form.operations.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
Add at least one station operation for this buildable item.
|
Add at least one station operation for this buildable item.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-3 space-y-3">
|
||||||
{form.operations.map((operation, index) => (
|
{form.operations.map((operation, index) => (
|
||||||
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]">
|
<div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]">
|
||||||
@@ -823,12 +830,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Bill Of Materials</p>
|
<p className="section-kicker">BILL OF MATERIALS</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Component lines</h4>
|
<h4 className="text-lg font-bold text-text">COMPONENT LINES</h4>
|
||||||
<p className="mt-2 text-sm text-muted">Add BOM components for manufactured or assembly items. Purchased and service items can be saved without BOM lines.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -839,11 +845,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{form.bomLines.length === 0 ? (
|
{form.bomLines.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No BOM lines added yet.
|
No BOM lines added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-3 space-y-3">
|
||||||
{form.bomLines.map((line, index) => (
|
{form.bomLines.map((line, index) => (
|
||||||
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
|
<div className="grid gap-3 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
|
||||||
@@ -974,7 +980,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-4 flex flex-col gap-2 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -41,14 +41,11 @@ export function InventoryListPage() {
|
|||||||
}, [searchTerm, statusFilter, token, typeFilter]);
|
}, [searchTerm, statusFilter, token, typeFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
|
<p className="section-kicker">INVENTORY</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Item Master</h3>
|
<h3 className="module-title">ITEM MASTER</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">
|
|
||||||
Core item and BOM definitions for purchased parts, manufactured items, assemblies, and service SKUs.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -61,7 +58,7 @@ export function InventoryListPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
|
<div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
@@ -100,13 +97,13 @@ export function InventoryListPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
|
||||||
No inventory items have been added yet.
|
No inventory items have been added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function InventorySkuMasterPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id} className="space-y-2">
|
<div key={node.id} className="space-y-2">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3" style={{ marginLeft: `${depth * 16}px` }}>
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2" style={{ marginLeft: `${depth * 16}px` }}>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -107,8 +107,10 @@ export function InventorySkuMasterPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold text-text">{node.code} <span className="text-muted">- {node.label}</span></div>
|
<div className="text-sm font-semibold text-text">
|
||||||
<div className="mt-1 text-xs text-muted">Level {node.level} • {node.childCount} child branch(es)</div>
|
{node.code} <span className="text-muted">- {node.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">Level {node.level} - {node.childCount} child branch(es)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,13 +195,12 @@ export function InventorySkuMasterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Master Data</p>
|
<p className="section-kicker">INVENTORY MASTER DATA</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">SKU Master Builder</h3>
|
<h3 className="module-title">SKU MASTER BUILDER</h3>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">Define family roots, branch-specific child codes, and the family-scoped short-code suffix that finishes each generated SKU.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Back to items
|
Back to items
|
||||||
@@ -207,13 +208,13 @@ export function InventorySkuMasterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.5fr]">
|
<div className="grid gap-3 xl:grid-cols-[0.9fr_1.5fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="text-sm font-semibold text-text">Families</div>
|
<p className="section-kicker">FAMILIES</p>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{catalog.families.length === 0 ? (
|
{catalog.families.length === 0 ? (
|
||||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">No SKU families defined yet.</div>
|
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-sm text-muted">No SKU families defined yet.</div>
|
||||||
) : (
|
) : (
|
||||||
catalog.families.map((family) => (
|
catalog.families.map((family) => (
|
||||||
<button
|
<button
|
||||||
@@ -224,13 +225,18 @@ export function InventorySkuMasterPage() {
|
|||||||
setExpandedNodeIds([]);
|
setExpandedNodeIds([]);
|
||||||
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
|
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
|
||||||
}}
|
}}
|
||||||
className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${
|
className={`block w-full rounded-[18px] border px-2 py-2 text-left transition ${
|
||||||
selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
|
selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-semibold text-text">{family.code} <span className="text-muted">({family.sequenceCode})</span></div>
|
<div className="text-sm font-semibold text-text">
|
||||||
|
{family.code} <span className="text-muted">({family.sequenceCode})</span>
|
||||||
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted">{family.name}</div>
|
<div className="mt-1 text-xs text-muted">{family.name}</div>
|
||||||
<div className="mt-2 text-xs text-muted">{family.childNodeCount} branch nodes • next {family.sequenceCode}{String(family.nextSequenceNumber).padStart(4, "0")}</div>
|
<div className="mt-2 text-xs text-muted">
|
||||||
|
{family.childNodeCount} branch nodes - next {family.sequenceCode}
|
||||||
|
{String(family.nextSequenceNumber).padStart(4, "0")}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -238,9 +244,9 @@ export function InventorySkuMasterPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="text-sm font-semibold text-text">Add family</div>
|
<p className="section-kicker">ADD FAMILY</p>
|
||||||
<form className="mt-4 space-y-3" onSubmit={handleCreateFamily}>
|
<form className="mt-3 space-y-3" onSubmit={handleCreateFamily}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
|
||||||
@@ -265,26 +271,26 @@ export function InventorySkuMasterPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-text">Branch tree</div>
|
<p className="section-kicker">BRANCH TREE</p>
|
||||||
<div className="mt-1 text-xs text-muted">{status}</div>
|
<div className="mt-1 text-xs text-muted">{status}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedFamilyId ? (
|
{selectedFamilyId ? (
|
||||||
<div className="text-xs text-muted">Up to 6 total SKU levels including family root.</div>
|
<div className="text-xs text-muted">Up to 6 total SKU levels.</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>}
|
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="text-sm font-semibold text-text">Add branch node</div>
|
<p className="section-kicker">ADD BRANCH NODE</p>
|
||||||
<form className="mt-4 space-y-3" onSubmit={handleCreateNode}>
|
<form className="mt-3 space-y-3" onSubmit={handleCreateNode}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
|
||||||
|
|||||||
@@ -32,18 +32,18 @@ export function WarehouseDetailPage() {
|
|||||||
}, [token, warehouseId]);
|
}, [token, warehouseId]);
|
||||||
|
|
||||||
if (!warehouse) {
|
if (!warehouse) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Detail</p>
|
<p className="section-kicker">WAREHOUSE DETAIL</p>
|
||||||
<h3 className="mt-2 text-2xl font-bold text-text">{warehouse.code}</h3>
|
<h3 className="module-title">{warehouse.code}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{warehouse.name}</p>
|
<p className="text-sm text-text">{warehouse.name}</p>
|
||||||
<p className="mt-3 text-sm text-muted">Last updated {new Date(warehouse.updatedAt).toLocaleString()}.</p>
|
<p className="mt-2 text-xs text-muted">Updated {new Date(warehouse.updatedAt).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link to="/inventory/warehouses" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<Link to="/inventory/warehouses" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
@@ -58,27 +58,26 @@ export function WarehouseDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
<p className="section-kicker">NOTES</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{warehouse.notes || "No warehouse notes recorded."}</p>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{warehouse.notes || "No warehouse notes recorded."}</p>
|
||||||
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||||
Created {new Date(warehouse.createdAt).toLocaleDateString()}
|
Created {new Date(warehouse.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p>
|
<p className="section-kicker">LOCATIONS</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Stock locations</h4>
|
|
||||||
{warehouse.locations.length === 0 ? (
|
{warehouse.locations.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No stock locations have been defined for this warehouse yet.
|
No stock locations have been defined for this warehouse yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 grid gap-3 xl:grid-cols-2">
|
<div className="mt-3 grid gap-2 xl:grid-cols-2">
|
||||||
{warehouse.locations.map((location: WarehouseLocationDto) => (
|
{warehouse.locations.map((location: WarehouseLocationDto) => (
|
||||||
<article key={location.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
<article key={location.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="text-sm font-semibold text-text">{location.code}</div>
|
<div className="text-sm font-semibold text-text">{location.code}</div>
|
||||||
<div className="mt-1 text-sm text-text">{location.name}</div>
|
<div className="mt-1 text-sm text-text">{location.name}</div>
|
||||||
<div className="mt-2 text-xs leading-6 text-muted">{location.notes || "No notes."}</div>
|
<div className="mt-2 text-xs text-muted">{location.notes || "No notes."}</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Editor</p>
|
<p className="section-kicker">WAREHOUSE EDITOR</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Warehouse" : "Edit Warehouse"}</h3>
|
<h3 className="module-title">{mode === "create" ? "NEW WAREHOUSE" : "EDIT WAREHOUSE"}</h3>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to={mode === "create" ? "/inventory/warehouses" : `/inventory/warehouses/${warehouseId}`}
|
to={mode === "create" ? "/inventory/warehouses" : `/inventory/warehouses/${warehouseId}`}
|
||||||
@@ -107,40 +107,39 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel space-y-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Warehouse code</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse code</span>
|
||||||
<input value={form.code} onChange={(event) => updateField("code", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={form.code} onChange={(event) => updateField("code", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Warehouse name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse name</span>
|
||||||
<input value={form.name} onChange={(event) => updateField("name", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={form.name} onChange={(event) => updateField("name", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p>
|
<p className="section-kicker">LOCATIONS</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Internal stock locations</h4>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={addLocation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={addLocation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Add location
|
Add location
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{form.locations.length === 0 ? (
|
{form.locations.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No locations added yet.
|
No locations added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-3 space-y-3">
|
||||||
{form.locations.map((location: WarehouseLocationInput, index: number) => (
|
{form.locations.map((location: WarehouseLocationInput, index: number) => (
|
||||||
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="grid gap-3 xl:grid-cols-[0.7fr_1fr_auto]">
|
<div className="grid gap-3 xl:grid-cols-[0.7fr_1fr_auto]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
||||||
@@ -156,7 +155,7 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="mt-4 block">
|
<label className="mt-3 block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||||
<input value={location.notes} onChange={(event) => updateLocation(index, { ...location, notes: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={location.notes} onChange={(event) => updateLocation(index, { ...location, notes: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
@@ -164,7 +163,7 @@ export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
{isSaving ? "Saving..." : mode === "create" ? "Create warehouse" : "Save changes"}
|
{isSaving ? "Saving..." : mode === "create" ? "Create warehouse" : "Save changes"}
|
||||||
|
|||||||
@@ -31,12 +31,11 @@ export function WarehousesPage() {
|
|||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
|
<p className="section-kicker">INVENTORY</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Warehouses</h3>
|
<h3 className="module-title">WAREHOUSES</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">Physical warehouse records and their internal stock locations.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link to="/inventory/warehouses/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
<Link to="/inventory/warehouses/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
@@ -44,13 +43,13 @@ export function WarehousesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-3 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
{warehouses.length === 0 ? (
|
{warehouses.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No warehouses have been added yet.
|
No warehouses have been added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-3 overflow-hidden rounded-2xl border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -79,20 +79,19 @@ export function ManufacturingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="page-stack">
|
||||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]">
|
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Stations</p>
|
<p className="section-kicker">MANUFACTURING STATIONS</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">Scheduling anchors</h3>
|
<h3 className="module-title">SCHEDULING ANCHORS</h3>
|
||||||
<p className="mt-2 text-sm text-muted">Stations define where operation time belongs. Buildable items reference them in their routing template, and work orders inherit those steps automatically into planning.</p>
|
|
||||||
{stations.length === 0 ? (
|
{stations.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No stations defined yet.
|
No stations defined yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{stations.map((station) => (
|
{stations.map((station) => (
|
||||||
<article key={station.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={station.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-text">{station.code} - {station.name}</div>
|
<div className="font-semibold text-text">{station.code} - {station.name}</div>
|
||||||
@@ -116,33 +115,33 @@ export function ManufacturingPage() {
|
|||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<form onSubmit={handleSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<form onSubmit={handleSubmit} className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{editingStationId ? "Edit Station" : "New Station"}</p>
|
<p className="section-kicker">{editingStationId ? "EDIT STATION" : "NEW STATION"}</p>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Code</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
||||||
<input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
|
||||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Expected Wait (Days)</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Expected wait (days)</span>
|
||||||
<input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Capacity Minutes / Day</span>
|
<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" />
|
<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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Parallel Capacity</span>
|
<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" />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Working Days</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Working days</span>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{[
|
||||||
{ value: 1, label: "Mon" },
|
{ value: 1, label: "Mon" },
|
||||||
@@ -172,7 +171,7 @@ export function ManufacturingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function WorkOrderDetailPage() {
|
|||||||
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
|
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
|
||||||
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
|
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
|
||||||
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
|
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
|
||||||
|
const [holdReasonDraft, setHoldReasonDraft] = useState("");
|
||||||
const [status, setStatus] = useState("Loading work order...");
|
const [status, setStatus] = useState("Loading work order...");
|
||||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||||
@@ -121,8 +122,12 @@ export function WorkOrderDetailPage() {
|
|||||||
setIsUpdatingStatus(true);
|
setIsUpdatingStatus(true);
|
||||||
setStatus("Updating work-order status...");
|
setStatus("Updating work-order status...");
|
||||||
try {
|
try {
|
||||||
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
|
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, {
|
||||||
|
status: nextStatus,
|
||||||
|
reason: nextStatus === "ON_HOLD" ? holdReasonDraft : null,
|
||||||
|
});
|
||||||
setWorkOrder(nextWorkOrder);
|
setWorkOrder(nextWorkOrder);
|
||||||
|
setHoldReasonDraft("");
|
||||||
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
||||||
@@ -332,6 +337,8 @@ export function WorkOrderDetailPage() {
|
|||||||
impact:
|
impact:
|
||||||
nextStatus === "CANCELLED"
|
nextStatus === "CANCELLED"
|
||||||
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
||||||
|
: nextStatus === "ON_HOLD"
|
||||||
|
? "Putting a work order on hold pauses expected execution and should capture the exact blocker so planning and shop-floor review stay aligned."
|
||||||
: nextStatus === "COMPLETE"
|
: nextStatus === "COMPLETE"
|
||||||
? "Completing the work order signals execution closure and can change readiness views across the system."
|
? "Completing the work order signals execution closure and can change readiness views across the system."
|
||||||
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
||||||
@@ -341,6 +348,7 @@ export function WorkOrderDetailPage() {
|
|||||||
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
||||||
nextStatus,
|
nextStatus,
|
||||||
});
|
});
|
||||||
|
setHoldReasonDraft(nextStatus === "ON_HOLD" ? workOrder.holdReason ?? "" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -383,14 +391,20 @@ export function WorkOrderDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Order</p>
|
<p className="section-kicker">WORK ORDER</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3>
|
<h3 className="module-title">{workOrder.workOrderNumber}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
|
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
|
||||||
<div className="mt-3"><WorkOrderStatusBadge status={workOrder.status} /></div>
|
<div className="mt-2.5"><WorkOrderStatusBadge status={workOrder.status} /></div>
|
||||||
|
{workOrder.status === "ON_HOLD" && workOrder.holdReason ? (
|
||||||
|
<div className="mt-2.5 max-w-2xl rounded-[16px] border border-amber-300/60 bg-amber-50 px-3 py-2.5 text-sm text-amber-900">
|
||||||
|
<div className="metric-kicker text-amber-900">Current Hold Reason</div>
|
||||||
|
<div className="mt-2 whitespace-pre-line">{workOrder.holdReason}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
|
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
|
||||||
@@ -402,11 +416,10 @@ export function WorkOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
<p className="section-kicker">QUICK ACTIONS</p>
|
||||||
<p className="mt-2 text-sm text-muted">Release, hold, or close administrative status from the work-order record.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{workOrderStatusOptions.map((option) => (
|
{workOrderStatusOptions.map((option) => (
|
||||||
@@ -418,20 +431,20 @@ export function WorkOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="grid gap-3 xl:grid-cols-6">
|
<section className="grid gap-2 xl:grid-cols-6">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Completed</p><div className="mt-1 text-base font-bold text-text">{workOrder.completedQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Remaining</p><div className="mt-1 text-base font-bold text-text">{workOrder.dueQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-1 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-1 text-base font-bold text-text">{workOrder.operations.length}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-1 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-1 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Actual Hours</p><div className="mt-2 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Actual Hours</p><div className="mt-1 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
|
||||||
</section>
|
</section>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Execution Context</p>
|
<p className="section-kicker">EXECUTION CONTEXT</p>
|
||||||
<dl className="mt-5 grid gap-3">
|
<dl className="mt-3 grid gap-3">
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
|
||||||
@@ -439,17 +452,17 @@ export function WorkOrderDetailPage() {
|
|||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Instructions</p>
|
<p className="section-kicker">WORK INSTRUCTIONS</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Operation Plan</p>
|
<p className="section-kicker">OPERATION PLAN</p>
|
||||||
{workOrder.operations.length === 0 ? (
|
{workOrder.operations.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 overflow-hidden rounded-[18px] border border-line/70">
|
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/70">
|
<thead className="bg-page/70">
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
@@ -634,11 +647,11 @@ export function WorkOrderDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<form onSubmit={handleIssueSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<form onSubmit={handleIssueSubmit} className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Issue</p>
|
<p className="section-kicker">MATERIAL ISSUE</p>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Component</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
|
||||||
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
<option value="">Select component</option>
|
<option value="">Select component</option>
|
||||||
{workOrder.materialRequirements.map((requirement) => (
|
{workOrder.materialRequirements.map((requirement) => (
|
||||||
@@ -648,7 +661,7 @@ export function WorkOrderDetailPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse</span>
|
||||||
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
|
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
|
||||||
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
|
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
|
||||||
@@ -656,7 +669,7 @@ export function WorkOrderDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Location</span>
|
||||||
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
<option value="">Select location</option>
|
<option value="">Select location</option>
|
||||||
{filteredLocationOptions.map((option) => (
|
{filteredLocationOptions.map((option) => (
|
||||||
@@ -665,12 +678,12 @@ export function WorkOrderDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
|
||||||
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||||
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
@@ -678,15 +691,15 @@ export function WorkOrderDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onSubmit={handleCompletionSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<form onSubmit={handleCompletionSubmit} className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Production Completion</p>
|
<p className="section-kicker">PRODUCTION COMPLETION</p>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
|
||||||
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||||
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
|
||||||
@@ -697,12 +710,12 @@ export function WorkOrderDetailPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Requirements</p>
|
<p className="section-kicker">MATERIAL REQUIREMENTS</p>
|
||||||
{workOrder.materialRequirements.length === 0 ? (
|
{workOrder.materialRequirements.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">This build item does not currently have BOM material requirements.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This build item does not currently have BOM material requirements.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 overflow-hidden rounded-[18px] border border-line/70">
|
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/70">
|
<thead className="bg-page/70">
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
@@ -733,14 +746,14 @@ export function WorkOrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Issue History</p>
|
<p className="section-kicker">ISSUE HISTORY</p>
|
||||||
{workOrder.materialIssues.length === 0 ? (
|
{workOrder.materialIssues.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No material issues have been posted yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No material issues have been posted yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{workOrder.materialIssues.map((issue) => (
|
{workOrder.materialIssues.map((issue) => (
|
||||||
<div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
<div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
|
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
|
||||||
@@ -755,14 +768,14 @@ export function WorkOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Completion History</p>
|
<p className="section-kicker">COMPLETION HISTORY</p>
|
||||||
{workOrder.completions.length === 0 ? (
|
{workOrder.completions.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No production completions have been posted yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No production completions have been posted yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{workOrder.completions.map((completion) => (
|
{workOrder.completions.map((completion) => (
|
||||||
<div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
<div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="font-semibold text-text">{completion.quantity} completed</div>
|
<div className="font-semibold text-text">{completion.quantity} completed</div>
|
||||||
<div className="text-xs text-muted">{completion.createdByName}</div>
|
<div className="text-xs text-muted">{completion.createdByName}</div>
|
||||||
@@ -793,6 +806,12 @@ export function WorkOrderDetailPage() {
|
|||||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
extraFieldLabel={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Hold reason" : undefined}
|
||||||
|
extraFieldPlaceholder={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Explain the blocker forcing this work order onto hold." : undefined}
|
||||||
|
extraFieldValue={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? holdReasonDraft : undefined}
|
||||||
|
extraFieldRequired={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
|
||||||
|
extraFieldMultiline={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
|
||||||
|
onExtraFieldChange={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? setHoldReasonDraft : undefined}
|
||||||
isConfirming={
|
isConfirming={
|
||||||
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||||
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
@@ -137,21 +137,24 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
navigate(mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Editor</p>
|
<p className="section-kicker">MANUFACTURING EDITOR</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Work Order" : "Edit Work Order"}</h3>
|
<h3 className="module-title">{mode === "create" ? "NEW WORK ORDER" : "EDIT WORK ORDER"}</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">Create a build record for a manufactured item, assign it to a project when needed, and define where completed output should post.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link to={mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel space-y-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
|
||||||
@@ -195,7 +198,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
setItemPickerOpen(false);
|
setItemPickerOpen(false);
|
||||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
<div className="font-semibold text-text">{option.sku}</div>
|
<div className="font-semibold text-text">{option.sku}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{option.name} · {option.type} · {option.operationCount} ops</div>
|
<div className="mt-1 text-xs text-muted">{option.name} - {option.type} - {option.operationCount} ops</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +255,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
setProjectPickerOpen(false);
|
setProjectPickerOpen(false);
|
||||||
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
<div className="font-semibold text-text">{option.projectNumber}</div>
|
<div className="font-semibold text-text">{option.projectNumber}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{option.name} · {option.customerName}</div>
|
<div className="mt-1 text-xs text-muted">{option.name} - {option.customerName}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -294,7 +297,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
<span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span>
|
||||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
{isSaving ? "Saving..." : mode === "create" ? "Create work order" : "Save changes"}
|
{isSaving ? "Saving..." : mode === "create" ? "Create work order" : "Save changes"}
|
||||||
|
|||||||
@@ -35,13 +35,12 @@ export function WorkOrderListPage() {
|
|||||||
}, [query, statusFilter, token]);
|
}, [query, statusFilter, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing</p>
|
<p className="section-kicker">MANUFACTURING</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">Work Orders</h3>
|
<h3 className="module-title">WORK ORDERS</h3>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">Release and execute build work against manufactured or assembly inventory items, with project linkage and real inventory posting.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link to="/manufacturing/work-orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
<Link to="/manufacturing/work-orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
@@ -50,8 +49,8 @@ export function WorkOrderListPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px]">
|
<div className="grid gap-2.5 xl:grid-cols-[minmax(0,1fr)_240px]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search work order, item, or project" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search work order, item, or project" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
@@ -63,7 +62,7 @@ export function WorkOrderListPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-3 rounded-[16px] border border-line/70 bg-page/70 px-3 py-2 text-sm text-muted">{status}</div>
|
||||||
</section>
|
</section>
|
||||||
{workOrders.length === 0 ? (
|
{workOrders.length === 0 ? (
|
||||||
<div className="rounded-[20px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
|
<div className="rounded-[20px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { permissions } from "@mrp/shared";
|
import { permissions } from "@mrp/shared";
|
||||||
import type { WorkOrderSummaryDto } from "@mrp/shared";
|
import type { ProjectMilestoneStatus, WorkOrderSummaryDto } from "@mrp/shared";
|
||||||
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
||||||
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
|
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -23,6 +23,7 @@ export function ProjectDetailPage() {
|
|||||||
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
||||||
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||||
const [status, setStatus] = useState("Loading project...");
|
const [status, setStatus] = useState("Loading project...");
|
||||||
|
const [updatingMilestoneId, setUpdatingMilestoneId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ export function ProjectDetailPage() {
|
|||||||
}, [projectId, token]);
|
}, [projectId, token]);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedMilestones = [...project.milestones].sort((left, right) => {
|
const sortedMilestones = [...project.milestones].sort((left, right) => {
|
||||||
@@ -107,15 +108,59 @@ export function ProjectDetailPage() {
|
|||||||
? "text-amber-600 dark:text-amber-300"
|
? "text-amber-600 dark:text-amber-300"
|
||||||
: "text-rose-600 dark:text-rose-300";
|
: "text-rose-600 dark:text-rose-300";
|
||||||
|
|
||||||
|
async function updateMilestoneStatus(milestoneId: string, nextStatus: ProjectMilestoneStatus) {
|
||||||
|
if (!token || !project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingMilestoneId(milestoneId);
|
||||||
|
setStatus("Updating milestone status...");
|
||||||
|
try {
|
||||||
|
const nextProject = await api.updateProjectMilestoneStatus(token, project.id, milestoneId, { status: nextStatus });
|
||||||
|
setProject(nextProject);
|
||||||
|
setStatus("Milestone status updated.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to update milestone status.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setUpdatingMilestoneId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestoneQuickActions(currentStatus: ProjectMilestoneStatus) {
|
||||||
|
if (currentStatus === "PLANNED") {
|
||||||
|
return [
|
||||||
|
{ status: "IN_PROGRESS" as const, label: "Start" },
|
||||||
|
{ status: "BLOCKED" as const, label: "Block" },
|
||||||
|
{ status: "COMPLETE" as const, label: "Complete" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (currentStatus === "IN_PROGRESS") {
|
||||||
|
return [
|
||||||
|
{ status: "BLOCKED" as const, label: "Block" },
|
||||||
|
{ status: "COMPLETE" as const, label: "Complete" },
|
||||||
|
{ status: "PLANNED" as const, label: "Reset" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (currentStatus === "BLOCKED") {
|
||||||
|
return [
|
||||||
|
{ status: "IN_PROGRESS" as const, label: "Resume" },
|
||||||
|
{ status: "COMPLETE" as const, label: "Complete" },
|
||||||
|
{ status: "PLANNED" as const, label: "Reset" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [{ status: "IN_PROGRESS" as const, label: "Reopen" }];
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project</p>
|
<p className="section-kicker">PROJECT</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{project.projectNumber}</h3>
|
<h3 className="module-title">{project.projectNumber}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{project.name}</p>
|
<p className="mt-1 text-sm text-text">{project.name}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-2.5 flex flex-wrap gap-2">
|
||||||
<ProjectStatusBadge status={project.status} />
|
<ProjectStatusBadge status={project.status} />
|
||||||
<ProjectPriorityBadge priority={project.priority} />
|
<ProjectPriorityBadge priority={project.priority} />
|
||||||
</div>
|
</div>
|
||||||
@@ -127,47 +172,45 @@ export function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-3 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Customer</p><div className="mt-2 text-base font-bold text-text">{project.customerName}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Customer</p><div className="mt-1 text-base font-bold text-text">{project.customerName}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Owner</p><div className="mt-2 text-base font-bold text-text">{project.ownerName || "Unassigned"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Owner</p><div className="mt-1 text-base font-bold text-text">{project.ownerName || "Unassigned"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-1 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-1 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-3 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Milestones</p><div className="mt-2 text-base font-bold text-text">{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.openMilestoneCount} open</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.openMilestoneCount} open</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Milestones</p><div className="mt-2 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Linked Work Orders</p><div className="mt-2 text-base font-bold text-text">{project.rollups.workOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.activeWorkOrderCount} active</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Linked Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.workOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.activeWorkOrderCount} active</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work Orders</p><div className="mt-2 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div></article>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project Cockpit</p>
|
<p className="section-kicker">PROJECT COCKPIT</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Cross-functional execution view</h4>
|
|
||||||
<p className="mt-2 text-sm text-muted">Commercial, supply, execution, purchasing, and delivery signals for this program in one place.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-right">
|
<div className="surface-panel-tight text-right">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone Progress</div>
|
<div className="metric-kicker">Milestone Progress</div>
|
||||||
<div className="mt-1 text-2xl font-bold text-text">{completionPercent}%</div>
|
<div className="mt-1 text-2xl font-bold text-text">{completionPercent}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
<div className="mt-3 grid gap-2.5 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.commercial.activeDocumentTotal)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.commercial.activeDocumentNumber ? `${project.cockpit.commercial.activeDocumentNumber} - ${project.cockpit.commercial.activeDocumentStatus}` : "Link a quote or sales order"}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.commercial.activeDocumentTotal)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.commercial.activeDocumentNumber ? `${project.cockpit.commercial.activeDocumentNumber} - ${project.cockpit.commercial.activeDocumentStatus}` : "Link a quote or sales order"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Supply</p><div className="mt-2 text-base font-bold text-text">{planning ? planning.summary.uncoveredItemCount : project.cockpit.risk.shortageItemCount} shortage items</div><div className="mt-1 text-xs text-muted">{planning ? `Build ${planning.summary.totalBuildQuantity} - Buy ${planning.summary.totalPurchaseQuantity}` : `Uncovered qty ${project.cockpit.risk.totalUncoveredQuantity}`}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Supply</p><div className="mt-2 text-base font-bold text-text">{planning ? planning.summary.uncoveredItemCount : project.cockpit.risk.shortageItemCount} shortage items</div><div className="mt-1 text-xs text-muted">{planning ? `Build ${planning.summary.totalBuildQuantity} - Buy ${planning.summary.totalPurchaseQuantity}` : `Uncovered qty ${project.cockpit.risk.totalUncoveredQuantity}`}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Execution</p><div className="mt-2 text-base font-bold text-text">{project.rollups.activeWorkOrderCount} active work orders</div><div className="mt-1 text-xs text-muted">{nextWorkOrder ? `${nextWorkOrder.workOrderNumber} due ${nextWorkOrder.dueDate ? new Date(nextWorkOrder.dueDate).toLocaleDateString() : "unscheduled"}` : "No active work order due date"}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Execution</p><div className="mt-2 text-base font-bold text-text">{project.rollups.activeWorkOrderCount} active work orders</div><div className="mt-1 text-xs text-muted">{nextWorkOrder ? `${nextWorkOrder.workOrderNumber} due ${nextWorkOrder.dueDate ? new Date(nextWorkOrder.dueDate).toLocaleDateString() : "unscheduled"}` : "No active work order due date"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Delivery</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.delivery.shipmentStatus ? project.cockpit.delivery.shipmentStatus.replaceAll("_", " ") : "Not linked"}</div><div className="mt-1 text-xs text-muted">{project.cockpit.delivery.shipmentNumber ? `${project.cockpit.delivery.shipmentNumber} - ${project.cockpit.delivery.packageCount} package(s)` : "Link a shipment to track delivery"}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Delivery</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.delivery.shipmentStatus ? project.cockpit.delivery.shipmentStatus.replaceAll("_", " ") : "Not linked"}</div><div className="mt-1 text-xs text-muted">{project.cockpit.delivery.shipmentNumber ? `${project.cockpit.delivery.shipmentNumber} - ${project.cockpit.delivery.packageCount} package(s)` : "Link a shipment to track delivery"}</div></article>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-3">
|
<div className="mt-3 grid gap-2.5 xl:grid-cols-3">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchasing Coverage</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.purchasing.totalReceivedQuantity}/{project.cockpit.purchasing.totalOrderedQuantity} received</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.linkedPurchaseOrderCount} linked PO(s) - {project.cockpit.purchasing.totalOutstandingQuantity} outstanding</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchasing Coverage</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.purchasing.totalReceivedQuantity}/{project.cockpit.purchasing.totalOrderedQuantity} received</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.linkedPurchaseOrderCount} linked PO(s) - {project.cockpit.purchasing.totalOutstandingQuantity} outstanding</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Readiness Score</p><div className={`mt-2 text-base font-bold ${riskTone}`}>{readinessScore}%</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.riskLevel} risk - {project.cockpit.risk.shortageItemCount} shortage item(s)</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Readiness Score</p><div className={`mt-2 text-base font-bold ${riskTone}`}>{readinessScore}%</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.riskLevel} risk - {project.cockpit.risk.shortageItemCount} shortage item(s)</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Spend</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.purchasing.linkedLineValue.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.vendorCount} vendor(s) across {project.cockpit.purchasing.linkedLineCount} linked line(s)</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Spend</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.purchasing.linkedLineValue.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{project.cockpit.purchasing.vendorCount} vendor(s) across {project.cockpit.purchasing.linkedLineCount} linked line(s)</div></article>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
<div className="mt-3 grid gap-2.5 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Booked Revenue</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.costs.bookedRevenue)}</div><div className="mt-1 text-xs text-muted">Quoted baseline {formatCurrency(project.cockpit.costs.quotedRevenue)}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Booked Revenue</p><div className="mt-2 text-base font-bold text-text">{formatCurrency(project.cockpit.costs.bookedRevenue)}</div><div className="mt-1 text-xs text-muted">Quoted baseline {formatCurrency(project.cockpit.costs.quotedRevenue)}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Commitment</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.linkedPurchaseCommitment.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Linked PO line value already committed</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Commitment</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.linkedPurchaseCommitment.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Linked PO line value already committed</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Material Cost</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.plannedMaterialCost.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Issued so far ${project.cockpit.costs.issuedMaterialCost.toFixed(2)}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Material Cost</p><div className="mt-2 text-base font-bold text-text">${project.cockpit.costs.plannedMaterialCost.toFixed(2)}</div><div className="mt-1 text-xs text-muted">Issued so far ${project.cockpit.costs.issuedMaterialCost.toFixed(2)}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Load</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.costs.completedBuildQuantity}/{project.cockpit.costs.buildQuantity}</div><div className="mt-1 text-xs text-muted">{project.cockpit.costs.plannedOperationHours.toFixed(1)} planned operation hours</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Load</p><div className="mt-2 text-base font-bold text-text">{project.cockpit.costs.completedBuildQuantity}/{project.cockpit.costs.buildQuantity}</div><div className="mt-1 text-xs text-muted">{project.cockpit.costs.plannedOperationHours.toFixed(1)} planned operation hours</div></article>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
<div className="mt-3 grid gap-2.5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Next Checkpoints</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Next Checkpoints</p>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
@@ -182,17 +225,16 @@ export function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Actionable Cockpit</p>
|
<p className="section-kicker">ACTIONABLE COCKPIT</p>
|
||||||
<p className="mt-2 text-sm text-muted">Turn current exceptions into purchasing, manufacturing, and planning follow-through.</p>
|
|
||||||
</div>
|
</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">
|
<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
|
Open workbench
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
<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">
|
<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>
|
<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-2 text-base font-bold text-text">{topBuildRecommendation ? topBuildRecommendation.itemSku : "No build recommendation"}</div>
|
||||||
@@ -224,7 +266,7 @@ export function ProjectDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-3">
|
<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">
|
<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
|
New project work order
|
||||||
</Link>
|
</Link>
|
||||||
@@ -238,92 +280,92 @@ export function ProjectDetailPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3"><div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Linked Purchasing</p><p className="mt-2 text-sm text-muted">Purchase orders and receipts tied back to the project sales order.</p></div>{project.salesOrderId ? <Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</Link> : null}</div>
|
<div className="flex items-center justify-between gap-3"><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-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-6 space-y-3">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
|
{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>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Readiness Drivers</p>
|
<p className="section-kicker">READINESS DRIVERS</p>
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Risk posture</div><div className={`mt-2 text-lg font-bold ${riskTone}`}>{project.cockpit.risk.riskLevel}</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.outstandingPurchaseOrderCount} PO(s) still waiting on receipts.</div></div>
|
<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 p-3 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">Blocked milestones: <span className="font-semibold">{project.cockpit.risk.blockedMilestoneCount}</span></div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm text-text">Overdue execution items: <span className="font-semibold">{project.cockpit.risk.overdueMilestoneCount + project.cockpit.risk.overdueWorkOrderCount}</span></div>
|
<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 p-3 text-sm text-text">Uncovered material quantity: <span className="font-semibold">{project.cockpit.risk.totalUncoveredQuantity}</span></div>
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Uncovered material quantity: <span className="font-semibold">{project.cockpit.risk.totalUncoveredQuantity}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
<section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor Exposure</p>
|
<p className="section-kicker">VENDOR EXPOSURE</p>
|
||||||
{project.cockpit.purchasing.vendors.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No supplier exposure exists until purchasing is linked.</div> : <div className="mt-5 space-y-3">{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (<div key={vendor.vendorId} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{vendor.vendorName}</div><div className="mt-1 text-xs text-muted">{vendor.orderCount} linked order(s)</div></div><div className="text-right text-xs text-muted"><div>${vendor.linkedLineValue.toFixed(2)}</div><div>{vendor.outstandingQuantity} outstanding qty</div></div></div></div>))}</div>}
|
{project.cockpit.purchasing.vendors.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No supplier exposure exists until purchasing is linked.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (<div key={vendor.vendorId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{vendor.vendorName}</div><div className="mt-1 text-xs text-muted">{vendor.orderCount} linked order(s)</div></div><div className="text-right text-xs text-muted"><div>${vendor.linkedLineValue.toFixed(2)}</div><div>{vendor.outstandingQuantity} outstanding qty</div></div></div></div>))}</div>}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Receipts</p>
|
<p className="section-kicker">RECENT RECEIPTS</p>
|
||||||
{project.cockpit.purchasing.recentReceipts.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase receipts have been posted against linked project supply.</div> : <div className="mt-5 space-y-3">{project.cockpit.purchasing.recentReceipts.map((receipt) => (<div key={receipt.receiptId} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{receipt.receiptNumber}</div><div className="mt-1 text-xs text-muted">{receipt.vendorName} - {receipt.purchaseOrderNumber}</div></div><div className="text-right text-xs text-muted"><div>{new Date(receipt.receivedAt).toLocaleDateString()}</div><div>{receipt.totalQuantity} units received</div></div></div></div>))}</div>}
|
{project.cockpit.purchasing.recentReceipts.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No purchase receipts have been posted against linked project supply.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.recentReceipts.map((receipt) => (<div key={receipt.receiptId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{receipt.receiptNumber}</div><div className="mt-1 text-xs text-muted">{receipt.vendorName} - {receipt.purchaseOrderNumber}</div></div><div className="text-right text-xs text-muted"><div>{new Date(receipt.receivedAt).toLocaleDateString()}</div><div>{receipt.totalQuantity} units received</div></div></div></div>))}</div>}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p>
|
<p className="section-kicker">CUSTOMER LINKAGE</p>
|
||||||
<dl className="mt-5 grid gap-3">
|
<dl className="mt-3 grid gap-3">
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</Link></dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</Link></dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{project.customerEmail}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{project.customerEmail}</dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Program Notes</p>
|
<p className="section-kicker">PROGRAM NOTES</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Commercial + Delivery Links</p>
|
<p className="section-kicker">COMMERCIAL + DELIVERY LINKS</p>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-3">
|
<div className="mt-3 grid gap-3 xl:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</div><div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</div><div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div></div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sales Order</div><div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sales Order</div><div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div></div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment</div><div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment</div><div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Milestones</p><p className="mt-2 text-sm text-muted">Track project checkpoints, blockers, and completion progress.</p></div>
|
<div><p className="section-kicker">MILESTONES</p></div>
|
||||||
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
||||||
</div>
|
</div>
|
||||||
{project.milestones.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-6 space-y-3">{project.milestones.map((milestone) => (<div key={milestone.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div></div></div>))}</div>}
|
{project.milestones.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-3 space-y-2">{project.milestones.map((milestone) => (<div key={milestone.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div>{canManage ? <div className="flex flex-wrap gap-2">{milestoneQuickActions(milestone.status).map((action) => (<button key={action.status} type="button" onClick={() => void updateMilestoneStatus(milestone.id, action.status)} disabled={updatingMilestoneId === milestone.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">{updatingMilestoneId === milestone.id ? "Saving..." : action.label}</button>))}</div> : null}</div></div>))}</div>}
|
||||||
</section>
|
</section>
|
||||||
{planning ? (
|
{planning ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Readiness</p>
|
<p className="section-kicker">MATERIAL READINESS</p>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
<div className="mt-3 grid gap-3 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p><div className="mt-2 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p><div className="mt-1 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div></article>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => (
|
{planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => (
|
||||||
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{item.itemSku}</div><div className="mt-1 text-xs text-muted">{item.itemName}</div></div><div className="text-sm text-muted">Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}</div></div></div>
|
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{item.itemSku}</div><div className="mt-1 text-xs text-muted">{item.itemName}</div></div><div className="text-sm text-muted">Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}</div></div></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Links</p><p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p></div>
|
<div><p className="section-kicker">MANUFACTURING LINKS</p></div>
|
||||||
{canManage ? <Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">New work order</Link> : null}
|
{canManage ? <Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">New work order</Link> : null}
|
||||||
</div>
|
</div>
|
||||||
{workOrders.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-6 space-y-3">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{workOrder.workOrderNumber}</div><div className="mt-1 text-xs text-muted">{workOrder.itemSku} - {workOrder.completedQuantity}/{workOrder.quantity} complete</div></div><div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div></div></Link>))}</div>}
|
{workOrders.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-3 space-y-2">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 transition hover:bg-page/80"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{workOrder.workOrderNumber}</div><div className="mt-1 text-xs text-muted">{workOrder.itemSku} - {workOrder.completedQuantity}/{workOrder.quantity} complete</div></div><div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div></div></Link>))}</div>}
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Activity Timeline</p><p className="mt-2 text-sm text-muted">Chronological project, milestone, purchasing, manufacturing, sales, and shipping history.</p></div>
|
<div><p className="section-kicker">ACTIVITY TIMELINE</p></div>
|
||||||
</div>
|
</div>
|
||||||
{project.timeline.length === 0 ? (
|
{project.timeline.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No timeline activity is available for this project yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No timeline activity is available for this project yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{project.timeline.map((entry) => (
|
{project.timeline.map((entry) => (
|
||||||
<div key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<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="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
|
||||||
|
|||||||
@@ -235,20 +235,19 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects Editor</p>
|
<p className="section-kicker">PROJECTS EDITOR</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Project" : "Edit Project"}</h3>
|
<h3 className="module-title">{mode === "create" ? "New Project" : "Edit Project"}</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">Create a customer-linked program record that can anchor commercial documents, delivery work, and project files.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link to={mode === "create" ? "/projects" : `/projects/${projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<Link to={mode === "create" ? "/projects" : `/projects/${projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="space-y-3 surface-panel">
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Project name</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Project name</span>
|
||||||
|
|||||||
@@ -42,13 +42,12 @@ export function ProjectListPage() {
|
|||||||
}, [priorityFilter, query, statusFilter, token]);
|
}, [priorityFilter, query, statusFilter, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects</p>
|
<p className="section-kicker">PROJECTS</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">Program records</h3>
|
<h3 className="module-title">PROGRAM RECORDS</h3>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">Track long-running customer programs across commercial commitments, shipment deliverables, ownership, and due dates.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link to="/projects/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
<Link to="/projects/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
@@ -57,8 +56,8 @@ export function ProjectListPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
|
<div className="grid gap-2.5 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Project number, name, customer" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Project number, name, customer" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
@@ -76,11 +75,11 @@ export function ProjectListPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-3 rounded-[16px] border border-line/70 bg-page/70 px-3 py-2 text-sm text-muted">{status}</div>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No projects are available for the current filters.</div>
|
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No projects are available for the current filters.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -292,11 +292,11 @@ export function PurchaseDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Order</p>
|
<p className="section-kicker">PURCHASE ORDER</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
|
<h3 className="module-title">{activeDocument.documentNumber}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
|
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<PurchaseStatusBadge status={activeDocument.status} />
|
<PurchaseStatusBadge status={activeDocument.status} />
|
||||||
@@ -326,11 +326,10 @@ export function PurchaseDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
<p className="section-kicker">QUICK ACTIONS</p>
|
||||||
<p className="mt-2 text-sm text-muted">Update purchase-order status without opening the full editor.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{purchaseStatusOptions.map((option) => (
|
{purchaseStatusOptions.map((option) => (
|
||||||
@@ -342,33 +341,32 @@ export function PurchaseDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-2 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p><div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p><div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Receipts</p><div className="mt-2 text-base font-bold text-text">{activeDocument.receipts.length}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Receipts</p><div className="mt-2 text-base font-bold text-text">{activeDocument.receipts.length}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Qty Remaining</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Qty Remaining</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}</div></article>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-2 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p><div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p><div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p><div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p><div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p><div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p><div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Payment Terms</p><div className="mt-2 text-base font-bold text-text">{activeDocument.paymentTerms || "N/A"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Payment Terms</p><div className="mt-2 text-base font-bold text-text">{activeDocument.paymentTerms || "N/A"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Currency</p><div className="mt-2 text-base font-bold text-text">{activeDocument.currencyCode || "USD"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Currency</p><div className="mt-2 text-base font-bold text-text">{activeDocument.currencyCode || "USD"}</div></article>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Revision History</p>
|
<p className="section-kicker">REVISION HISTORY</p>
|
||||||
<p className="mt-2 text-sm text-muted">Automatic snapshots are recorded when the purchase order changes or receipts are posted.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeDocument.revisions.length === 0 ? (
|
{activeDocument.revisions.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No revisions have been recorded yet.
|
No revisions recorded yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{activeDocument.revisions.map((revision) => (
|
{activeDocument.revisions.map((revision) => (
|
||||||
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -419,26 +417,37 @@ export function PurchaseDetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor</p>
|
<p className="section-kicker">VENDOR</p>
|
||||||
<dl className="mt-5 grid gap-3">
|
<dl className="mt-5 grid gap-3">
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/vendors/${activeDocument.vendorId}`} className="hover:text-brand">{activeDocument.vendorName}</Link></dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/vendors/${activeDocument.vendorId}`} className="hover:text-brand">{activeDocument.vendorName}</Link></dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorEmail}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorEmail}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
<p className="section-kicker">PROJECT LINK</p>
|
||||||
|
{activeDocument.projectId ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Link to={`/projects/${activeDocument.projectId}`} className="inline-flex items-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text hover:bg-page/70">
|
||||||
|
{activeDocument.projectNumber} / {activeDocument.projectName}
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-muted">Project cockpit and rollups use this linkage.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-muted">No linked project.</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Context</p>
|
<p className="section-kicker">DEMAND CONTEXT</p>
|
||||||
{demandContextItems.length === 0 ? (
|
{demandContextItems.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No active shared shortage or buy-signal records currently point at items on this purchase order.
|
No active shortage or buy-signal context for these items.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{demandContextItems.map((item) => (
|
{demandContextItems.map((item) => (
|
||||||
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -455,12 +464,12 @@ export function PurchaseDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
<p className="section-kicker">LINE ITEMS</p>
|
||||||
{activeDocument.lines.length === 0 ? (
|
{activeDocument.lines.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No line items have been added yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No line items added yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-3 overflow-hidden rounded-2xl border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
|
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
|
||||||
@@ -488,16 +497,14 @@ export function PurchaseDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
|
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
|
||||||
{canReceive ? (
|
{canReceive ? (
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Receiving</p>
|
<p className="section-kicker">PURCHASE RECEIVING</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Receive material</h4>
|
|
||||||
<p className="mt-2 text-sm text-muted">Post received quantities to inventory and retain a receipt record against this order.</p>
|
|
||||||
{openLines.length === 0 ? (
|
{openLines.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||||
All ordered quantities have been received for this purchase order.
|
All ordered quantities have been received for this purchase order.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form className="mt-5 space-y-4" onSubmit={handleReceiptSubmit}>
|
<form className="mt-3 space-y-3" onSubmit={handleReceiptSubmit}>
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Receipt date</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Receipt date</span>
|
||||||
@@ -569,7 +576,7 @@ export function PurchaseDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{receiptStatus}</span>
|
<span className="min-w-0 text-sm text-muted">{receiptStatus}</span>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -583,15 +590,14 @@ export function PurchaseDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Receipt History</p>
|
<p className="section-kicker">RECEIPT HISTORY</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Received material log</h4>
|
|
||||||
{activeDocument.receipts.length === 0 ? (
|
{activeDocument.receipts.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No purchase receipts have been recorded for this order yet.
|
No purchase receipts recorded yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{activeDocument.receipts.map((receipt) => (
|
{activeDocument.receipts.map((receipt) => (
|
||||||
<article key={receipt.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={receipt.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
|
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
@@ -14,6 +14,9 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
const { orderId } = useParams();
|
const { orderId } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const seededVendorId = searchParams.get("vendorId");
|
const seededVendorId = searchParams.get("vendorId");
|
||||||
|
const seededProjectId = searchParams.get("projectId");
|
||||||
|
const seededProjectNumber = searchParams.get("projectNumber");
|
||||||
|
const seededProjectName = searchParams.get("projectName");
|
||||||
const planningOrderId = searchParams.get("planningOrderId");
|
const planningOrderId = searchParams.get("planningOrderId");
|
||||||
const selectedPlanningItemId = searchParams.get("itemId");
|
const selectedPlanningItemId = searchParams.get("itemId");
|
||||||
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
|
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
|
||||||
@@ -57,6 +60,12 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
|
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
|
||||||
}, [mode, seededVendorId, token]);
|
}, [mode, seededVendorId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "create" && seededProjectId) {
|
||||||
|
setForm((current) => ({ ...current, projectId: current.projectId || seededProjectId }));
|
||||||
|
}
|
||||||
|
}, [mode, seededProjectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
|
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -103,6 +112,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
vendorId: current.vendorId || autoVendorId || "",
|
vendorId: current.vendorId || autoVendorId || "",
|
||||||
|
projectId: current.projectId || seededProjectId || null,
|
||||||
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
|
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
|
||||||
lines: current.lines.length > 0 ? current.lines : recommendedLines,
|
lines: current.lines.length > 0 ? current.lines : recommendedLines,
|
||||||
}));
|
}));
|
||||||
@@ -124,7 +134,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
setStatus("Unable to load demand-planning recommendations.");
|
setStatus("Unable to load demand-planning recommendations.");
|
||||||
});
|
});
|
||||||
}, [itemOptions, mode, planningOrderId, seededVendorId, selectedPlanningItemId, token, vendors]);
|
}, [itemOptions, mode, planningOrderId, seededProjectId, seededVendorId, selectedPlanningItemId, token, vendors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || mode !== "edit" || !orderId) {
|
if (!token || mode !== "edit" || !orderId) {
|
||||||
@@ -135,6 +145,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
.then((document) => {
|
.then((document) => {
|
||||||
setForm({
|
setForm({
|
||||||
vendorId: document.vendorId,
|
vendorId: document.vendorId,
|
||||||
|
projectId: document.projectId,
|
||||||
status: document.status,
|
status: document.status,
|
||||||
issueDate: document.issueDate,
|
issueDate: document.issueDate,
|
||||||
taxPercent: document.taxPercent,
|
taxPercent: document.taxPercent,
|
||||||
@@ -252,20 +263,24 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
navigate(mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Editor</p>
|
<p className="section-kicker">PURCHASING EDITOR</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
|
<h3 className="module-title">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
|
||||||
</div>
|
</div>
|
||||||
<Link to={mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="space-y-3 surface-panel">
|
||||||
<div className="grid gap-3 xl:grid-cols-4">
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
<label className="block xl:col-span-2">
|
<label className="block xl:col-span-2">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Vendor</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Vendor</span>
|
||||||
@@ -341,6 +356,14 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Linked Project</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">
|
||||||
|
{mode === "edit"
|
||||||
|
? (form.projectId ? "Project context saved on this purchase order." : "No project linked.")
|
||||||
|
: (seededProjectId ? `${seededProjectNumber || "Project"}${seededProjectName ? ` - ${seededProjectName}` : ""}` : "Will auto-link from sales-order demand when possible.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
@@ -367,18 +390,18 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
<p className="section-kicker">LINE ITEMS</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Procurement lines</h4>
|
<h4 className="text-lg font-bold text-text">PROCUREMENT LINES</h4>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add line</button>
|
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add line</button>
|
||||||
</div>
|
</div>
|
||||||
{form.lines.length === 0 ? (
|
{form.lines.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No line items added yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No line items added yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-3 space-y-3">
|
||||||
{form.lines.map((line: PurchaseLineInput, index: number) => (
|
{form.lines.map((line: PurchaseLineInput, index: number) => (
|
||||||
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
||||||
@@ -454,13 +477,13 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-3 xl:grid-cols-4">
|
<div className="mt-4 grid gap-2 md:grid-cols-3 xl:grid-cols-4">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div><div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div><div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div></div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Tax</div><div className="mt-1 font-semibold text-text">${taxAmount.toFixed(2)}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Tax</div><div className="mt-1 font-semibold text-text">${taxAmount.toFixed(2)}</div></div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Freight</div><div className="mt-1 font-semibold text-text">${form.freightAmount.toFixed(2)}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Freight</div><div className="mt-1 font-semibold text-text">${form.freightAmount.toFixed(2)}</div></div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div><div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div><div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-4 flex flex-col gap-2 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
{isSaving ? "Saving..." : mode === "create" ? "Create purchase order" : "Save changes"}
|
{isSaving ? "Saving..." : mode === "create" ? "Create purchase order" : "Save changes"}
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ export function PurchaseListPage() {
|
|||||||
}, [searchTerm, statusFilter, token]);
|
}, [searchTerm, statusFilter, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing</p>
|
<p className="section-kicker">PURCHASING</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Purchase Orders</h3>
|
<h3 className="module-title">PURCHASE ORDERS</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">Vendor-facing procurement documents for material replenishment and bought-in components.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link to="/purchasing/orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
<Link to="/purchasing/orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
@@ -47,7 +46,7 @@ export function PurchaseListPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
<div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input value={searchTerm} onChange={(event) => setSearchTerm(event.target.value)} placeholder="Search purchase orders by document number or vendor" className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={searchTerm} onChange={(event) => setSearchTerm(event.target.value)} placeholder="Search purchase orders by document number or vendor" className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
@@ -63,11 +62,11 @@ export function PurchaseListPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
|
||||||
{documents.length === 0 ? (
|
{documents.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase orders have been added yet.</div>
|
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No purchase orders have been added yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
|
|||||||
|
|
||||||
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
|
projectId: null,
|
||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
issueDate: new Date().toISOString(),
|
issueDate: new Date().toISOString(),
|
||||||
taxPercent: 0,
|
taxPercent: 0,
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
|
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
|
||||||
});
|
});
|
||||||
|
if (activeDocument.linkedProjectId) {
|
||||||
|
params.set("projectId", activeDocument.linkedProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
return `/manufacturing/work-orders/new?${params.toString()}`;
|
return `/manufacturing/work-orders/new?${params.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -186,6 +189,15 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
if (vendorId) {
|
if (vendorId) {
|
||||||
params.set("vendorId", vendorId);
|
params.set("vendorId", vendorId);
|
||||||
}
|
}
|
||||||
|
if (activeDocument.linkedProjectId) {
|
||||||
|
params.set("projectId", activeDocument.linkedProjectId);
|
||||||
|
}
|
||||||
|
if (activeDocument.linkedProjectNumber) {
|
||||||
|
params.set("projectNumber", activeDocument.linkedProjectNumber);
|
||||||
|
}
|
||||||
|
if (activeDocument.linkedProjectName) {
|
||||||
|
params.set("projectName", activeDocument.linkedProjectName);
|
||||||
|
}
|
||||||
|
|
||||||
return `/purchasing/orders/new?${params.toString()}`;
|
return `/purchasing/orders/new?${params.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -322,12 +334,12 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="page-stack">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow}</p>
|
<p className="section-kicker">{config.detailEyebrow.toUpperCase()}</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
|
<h3 className="module-title">{activeDocument.documentNumber}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
|
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<SalesStatusBadge status={activeDocument.status} />
|
<SalesStatusBadge status={activeDocument.status} />
|
||||||
@@ -384,11 +396,10 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
<p className="section-kicker">QUICK ACTIONS</p>
|
||||||
<p className="mt-2 text-sm text-muted">Update document status without opening the full editor.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{salesStatusOptions.map((option) => (
|
{salesStatusOptions.map((option) => (
|
||||||
@@ -406,58 +417,57 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-2 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div>
|
<div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Expires</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Expires</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{activeDocument.expiresAt ? new Date(activeDocument.expiresAt).toLocaleDateString() : "N/A"}</div>
|
<div className="mt-2 text-base font-bold text-text">{activeDocument.expiresAt ? new Date(activeDocument.expiresAt).toLocaleDateString() : "N/A"}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div>
|
<div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Approval</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Approval</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div>
|
<div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
|
<div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-2 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Discount</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Discount</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">-${activeDocument.discountAmount.toFixed(2)}</div>
|
<div className="mt-2 text-base font-bold text-text">-${activeDocument.discountAmount.toFixed(2)}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{activeDocument.discountPercent.toFixed(2)}%</div>
|
<div className="mt-1 text-xs text-muted">{activeDocument.discountPercent.toFixed(2)}%</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div>
|
<div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div>
|
<div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div>
|
<div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div>
|
<div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Revision History</p>
|
<p className="section-kicker">REVISION HISTORY</p>
|
||||||
<p className="mt-2 text-sm text-muted">Automatic snapshots are recorded when the document changes status, content, or approval state.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeDocument.revisions.length === 0 ? (
|
{activeDocument.revisions.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No revisions have been recorded yet.
|
No revisions recorded yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{activeDocument.revisions.map((revision) => (
|
{activeDocument.revisions.map((revision) => (
|
||||||
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -507,8 +517,8 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer</p>
|
<p className="section-kicker">CUSTOMER</p>
|
||||||
<dl className="mt-5 grid gap-3">
|
<dl className="mt-5 grid gap-3">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt>
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt>
|
||||||
@@ -520,19 +530,30 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
<p className="section-kicker">PROJECT LINK</p>
|
||||||
|
{activeDocument.linkedProjectId ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Link to={`/projects/${activeDocument.linkedProjectId}`} className="inline-flex items-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text hover:bg-page/70">
|
||||||
|
{activeDocument.linkedProjectNumber} / {activeDocument.linkedProjectName}
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-muted">Downstream WO and PO launches carry this project context.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-muted">No linked project.</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
<p className="section-kicker">LINE ITEMS</p>
|
||||||
{activeDocument.lines.length === 0 ? (
|
{activeDocument.lines.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No line items have been added yet.
|
No line items added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-3 overflow-hidden rounded-2xl border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -564,37 +585,33 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
{entity === "order" && planning ? (
|
{entity === "order" && planning ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Planning</p>
|
<p className="section-kicker">DEMAND PLANNING</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Net build and buy requirements</h3>
|
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
|
||||||
Sales-order demand is netted against available stock, active reservations, open work orders, and open purchase orders before new build or buy quantities are recommended.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-xs text-muted">
|
<div className="text-right text-xs text-muted">
|
||||||
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
|
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
|
||||||
<div>Status {planning.status}</div>
|
<div>Status {planning.status}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
<div className="mt-4 grid gap-2 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
<article className="surface-panel-tight bg-page/70 shadow-none">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div>
|
<div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
<article className="surface-panel-tight bg-page/70 shadow-none">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div>
|
<div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
<article className="surface-panel-tight bg-page/70 shadow-none">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
|
<div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
<article className="surface-panel-tight bg-page/70 shadow-none">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div>
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div>
|
<div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div>
|
||||||
@@ -665,7 +682,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-4 space-y-2">
|
||||||
{planning.lines.map((line) => (
|
{planning.lines.map((line) => (
|
||||||
<div key={line.lineId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={line.lineId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -683,11 +700,10 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{entity === "order" && canReadShipping ? (
|
{entity === "order" && canReadShipping ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
|
<p className="section-kicker">SHIPPING</p>
|
||||||
<p className="mt-2 text-sm text-muted">Shipment records currently tied to this sales order.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManageShipping ? (
|
{canManageShipping ? (
|
||||||
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
@@ -696,11 +712,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{shipments.length === 0 ? (
|
{shipments.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No shipments have been created for this sales order yet.
|
No shipments created yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{shipments.map((shipment) => (
|
{shipments.map((shipment) => (
|
||||||
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/types.js";
|
import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/types.js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
@@ -167,20 +167,24 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
navigate(mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow} Editor</p>
|
<p className="section-kicker">{`${config.detailEyebrow} EDITOR`.toUpperCase()}</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
|
<h3 className="module-title">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
|
||||||
</div>
|
</div>
|
||||||
<Link to={mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="space-y-3 surface-panel">
|
||||||
<div className="grid gap-3 xl:grid-cols-4">
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
<label className="block xl:col-span-2">
|
<label className="block xl:col-span-2">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
|
||||||
@@ -351,22 +355,22 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
<p className="section-kicker">LINE ITEMS</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">Commercial lines</h4>
|
<h4 className="text-lg font-bold text-text">COMMERCIAL LINES</h4>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Add line
|
Add line
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{form.lines.length === 0 ? (
|
{form.lines.length === 0 ? (
|
||||||
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No line items added yet.
|
No line items added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-4">
|
<div className="mt-3 space-y-3">
|
||||||
{form.lines.map((line: SalesLineInput, index: number) => (
|
{form.lines.map((line: SalesLineInput, index: number) => (
|
||||||
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
||||||
@@ -451,7 +455,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div>
|
||||||
<div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div>
|
<div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div>
|
||||||
@@ -469,7 +473,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
|||||||
<div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div>
|
<div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-4 flex flex-col gap-2 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
{isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel.toLowerCase()}` : "Save changes"}
|
{isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel.toLowerCase()}` : "Save changes"}
|
||||||
|
|||||||
@@ -40,14 +40,11 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
|
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.listEyebrow}</p>
|
<p className="section-kicker">{config.listEyebrow.toUpperCase()}</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3>
|
<h3 className="module-title">{config.collectionLabel}</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">
|
|
||||||
Customer-facing commercial documents for pricing, commitment, and downstream fulfillment planning.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link to={`${config.routeBase}/new`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
<Link to={`${config.routeBase}/new`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
@@ -55,7 +52,7 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
<div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
@@ -80,13 +77,13 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
|
||||||
{documents.length === 0 ? (
|
{documents.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">
|
||||||
No {config.collectionLabel.toLowerCase()} have been added yet.
|
No {config.collectionLabel.toLowerCase()} have been added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
||||||
|
|
||||||
if (!diagnostics || !backupGuidance) {
|
if (!diagnostics || !backupGuidance) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportSupportSnapshot() {
|
async function handleExportSupportSnapshot() {
|
||||||
@@ -156,15 +156,12 @@ export function AdminDiagnosticsPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="page-stack">
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin Diagnostics</p>
|
<p className="section-kicker">ADMIN DIAGNOSTICS</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Operational runtime and audit visibility</h3>
|
<h3 className="module-title">RUNTIME AUDIT SUPPORT</h3>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
|
||||||
This view surfaces environment footprint, record counts, and recent change activity so admin review does not require direct database access.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -189,35 +186,31 @@ export function AdminDiagnosticsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{summaryCards.map(([label, value]) => (
|
{summaryCards.map(([label, value]) => (
|
||||||
<div key={label} className="rounded-[18px] border border-line/70 bg-page/70 p-4">
|
<div key={label} className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
|
||||||
<p className="mt-3 text-lg font-bold text-text">{value}</p>
|
<p className="mt-2 text-lg font-bold text-text">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Backup And Restore</p>
|
<p className="section-kicker">BACKUP AND RESTORE</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Operational backup workflow</h3>
|
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
|
||||||
Use these paths and steps as the support baseline for manual backup and restore procedures.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||||
<div>Data: {backupGuidance.dataPath}</div>
|
<div>Data: {backupGuidance.dataPath}</div>
|
||||||
<div>DB: {backupGuidance.databasePath}</div>
|
<div>DB: {backupGuidance.databasePath}</div>
|
||||||
<div>Uploads: {backupGuidance.uploadsPath}</div>
|
<div>Uploads: {backupGuidance.uploadsPath}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Backup checklist</p>
|
<p className="text-sm font-semibold text-text">Backup checklist</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.backupSteps.map((step) => (
|
{backupGuidance.backupSteps.map((step) => (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
@@ -226,9 +219,9 @@ export function AdminDiagnosticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Restore checklist</p>
|
<p className="text-sm font-semibold text-text">Restore checklist</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.restoreSteps.map((step) => (
|
{backupGuidance.restoreSteps.map((step) => (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
@@ -238,10 +231,10 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
|
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.verificationChecklist.map((item) => (
|
{backupGuidance.verificationChecklist.map((item) => (
|
||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
<p className="text-sm font-semibold text-text">{item.label}</p>
|
<p className="text-sm font-semibold text-text">{item.label}</p>
|
||||||
@@ -251,9 +244,9 @@ export function AdminDiagnosticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
|
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.restoreDrillSteps.map((step) => (
|
{backupGuidance.restoreDrillSteps.map((step) => (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
@@ -266,17 +259,16 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Startup Validation</p>
|
<p className="section-kicker">STARTUP VALIDATION</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
|
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
|
||||||
{diagnostics.startup.status}
|
{diagnostics.startup.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
{diagnostics.startup.checks.map((check) => (
|
{diagnostics.startup.checks.map((check) => (
|
||||||
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -287,7 +279,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 lg:grid-cols-3">
|
<div className="mt-3 grid gap-3 lg:grid-cols-3">
|
||||||
{startupSummaryCards.map(([label, value]) => (
|
{startupSummaryCards.map(([label, value]) => (
|
||||||
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
@@ -297,9 +289,9 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p>
|
<p className="section-kicker">SYSTEM FOOTPRINT</p>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
{footprintCards.map(([label, value]) => (
|
{footprintCards.map(([label, value]) => (
|
||||||
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
@@ -309,19 +301,18 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Support Logs</p>
|
<p className="section-kicker">SUPPORT LOGS</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
|
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
value={supportLogQuery}
|
value={supportLogQuery}
|
||||||
onChange={(event) => setSupportLogQuery(event.target.value)}
|
onChange={(event) => setSupportLogQuery(event.target.value)}
|
||||||
@@ -330,7 +321,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Level</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level</span>
|
||||||
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
<option value="ALL">All levels</option>
|
<option value="ALL">All levels</option>
|
||||||
<option value="ERROR">Error</option>
|
<option value="ERROR">Error</option>
|
||||||
@@ -339,7 +330,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Source</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Source</span>
|
||||||
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
<option value="ALL">All sources</option>
|
<option value="ALL">All sources</option>
|
||||||
{supportLogSources.map((source) => (
|
{supportLogSources.map((source) => (
|
||||||
@@ -348,7 +339,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Window</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Window</span>
|
||||||
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
<option value="ALL">All retained</option>
|
<option value="ALL">All retained</option>
|
||||||
<option value="1">Last 24 hours</option>
|
<option value="1">Last 24 hours</option>
|
||||||
@@ -356,13 +347,13 @@ export function AdminDiagnosticsPage() {
|
|||||||
<option value="14">Last 14 days</option>
|
<option value="14">Last 14 days</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||||
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
|
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
|
||||||
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
|
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
|
||||||
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
|
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 overflow-x-auto">
|
<div className="mt-3 overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
@@ -404,15 +395,14 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Audit Trail</p>
|
<p className="section-kicker">RECENT AUDIT TRAIL</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Latest cross-module write activity</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted">{status}</p>
|
<p className="text-sm text-muted">{status}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 overflow-x-auto">
|
<div className="mt-3 overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function CompanySettingsPage() {
|
|||||||
}, [logoUrl]);
|
}, [logoUrl]);
|
||||||
|
|
||||||
if (!form || !token) {
|
if (!form || !token) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -145,14 +145,13 @@ export function CompanySettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSave}>
|
<form className="page-stack" onSubmit={handleSave}>
|
||||||
{user?.permissions.includes("admin.manage") ? (
|
{user?.permissions.includes("admin.manage") ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
|
<p className="section-kicker">ADMIN</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3>
|
<h3 className="module-title">ADMIN SURFACES</h3>
|
||||||
<p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
@@ -165,14 +164,13 @@ export function CompanySettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p>
|
<p className="section-kicker">COMPANY PROFILE</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Branding and legal identity</h3>
|
<h3 className="module-title">BRANDING AND LEGAL IDENTITY</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">Every internal document and PDF template will inherit its company identity from this profile.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/80 p-4">
|
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/80 px-3 py-3">
|
||||||
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
|
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
|
||||||
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
|
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
|
||||||
Upload logo
|
Upload logo
|
||||||
@@ -180,7 +178,7 @@ export function CompanySettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
{[
|
{[
|
||||||
["companyName", "Company name"],
|
["companyName", "Company name"],
|
||||||
["legalName", "Legal name"],
|
["legalName", "Legal name"],
|
||||||
@@ -196,7 +194,7 @@ export function CompanySettingsPage() {
|
|||||||
["country", "Country"],
|
["country", "Country"],
|
||||||
].map(([key, label]) => (
|
].map(([key, label]) => (
|
||||||
<label key={key} className="block">
|
<label key={key} className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">{label}</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">{label}</span>
|
||||||
<input
|
<input
|
||||||
value={String(form[key as keyof CompanyProfileInput])}
|
value={String(form[key as keyof CompanyProfileInput])}
|
||||||
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
|
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
|
||||||
@@ -206,27 +204,27 @@ export function CompanySettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
|
<p className="section-kicker">THEME</p>
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-4">
|
<div className="mt-3 grid gap-3 md:grid-cols-2 2xl:grid-cols-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Primary color</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Primary color</span>
|
||||||
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Accent color</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Accent color</span>
|
||||||
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Surface color</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Surface color</span>
|
||||||
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Font family</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Font family</span>
|
||||||
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between">
|
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -272,15 +272,12 @@ export function UserManagementPage() {
|
|||||||
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
|
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="page-stack">
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">User Management</p>
|
<p className="section-kicker">ADMIN</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Accounts, roles, and permission assignment</h3>
|
<h3 className="module-title">USERS ROLES SESSIONS</h3>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
|
||||||
Manage user accounts and the role-permission model from one admin surface so onboarding and access control stay tied together.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
@@ -293,12 +290,11 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleUserSave}>
|
<form className="surface-panel backdrop-blur" onSubmit={handleUserSave}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Users</p>
|
<p className="section-kicker">USERS</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Account generation and role assignment</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={selectedUserId}
|
value={selectedUserId}
|
||||||
@@ -314,9 +310,9 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Email</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.email}
|
value={userForm.email}
|
||||||
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
||||||
@@ -324,7 +320,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={userForm.password ?? ""}
|
value={userForm.password ?? ""}
|
||||||
@@ -334,7 +330,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">First name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">First name</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.firstName}
|
value={userForm.firstName}
|
||||||
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
|
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
|
||||||
@@ -342,7 +338,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Last name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Last name</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.lastName}
|
value={userForm.lastName}
|
||||||
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
|
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
|
||||||
@@ -351,7 +347,7 @@ export function UserManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
<label className="mt-3 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={userForm.isActive}
|
checked={userForm.isActive}
|
||||||
@@ -360,11 +356,11 @@ export function UserManagementPage() {
|
|||||||
User can sign in
|
User can sign in
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-3">
|
||||||
<p className="text-sm font-semibold text-text">Assigned roles</p>
|
<p className="section-kicker">ASSIGNED ROLES</p>
|
||||||
<div className="mt-3 grid gap-3">
|
<div className="mt-3 grid gap-2">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={userForm.roleIds.includes(role.id)}
|
checked={userForm.roleIds.includes(role.id)}
|
||||||
@@ -379,7 +375,7 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
<span className="text-sm text-muted">{status}</span>
|
<span className="text-sm text-muted">{status}</span>
|
||||||
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||||
{selectedUserId === "new" ? "Create user" : "Save user"}
|
{selectedUserId === "new" ? "Create user" : "Save user"}
|
||||||
@@ -387,11 +383,10 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleRoleSave}>
|
<form className="surface-panel backdrop-blur" onSubmit={handleRoleSave}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roles</p>
|
<p className="section-kicker">ROLES</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Permission assignment administration</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={selectedRoleId}
|
value={selectedRoleId}
|
||||||
@@ -407,9 +402,9 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Role name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Role name</span>
|
||||||
<input
|
<input
|
||||||
value={roleForm.name}
|
value={roleForm.name}
|
||||||
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
|
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
@@ -417,7 +412,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={roleForm.description}
|
value={roleForm.description}
|
||||||
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
|
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
|
||||||
@@ -427,11 +422,11 @@ export function UserManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-3">
|
||||||
<p className="text-sm font-semibold text-text">Role permissions</p>
|
<p className="section-kicker">ROLE PERMISSIONS</p>
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
{permissions.map((permission) => (
|
{permissions.map((permission) => (
|
||||||
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={roleForm.permissionKeys.includes(permission.key)}
|
checked={roleForm.permissionKeys.includes(permission.key)}
|
||||||
@@ -446,9 +441,9 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
<div className="mt-3 grid gap-2 md:grid-cols-3">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
<p className="text-sm font-semibold text-text">{role.name}</p>
|
<p className="text-sm font-semibold text-text">{role.name}</p>
|
||||||
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
|
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
|
||||||
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
|
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
|
||||||
@@ -456,7 +451,7 @@ export function UserManagementPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
<span className="text-sm text-muted">{status}</span>
|
<span className="text-sm text-muted">{status}</span>
|
||||||
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||||
{selectedRoleId === "new" ? "Create role" : "Save role"}
|
{selectedRoleId === "new" ? "Create role" : "Save role"}
|
||||||
@@ -465,18 +460,14 @@ export function UserManagementPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sessions</p>
|
<p className="section-kicker">SESSIONS</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Active sign-ins and revocation control</h3>
|
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
|
||||||
Review recent authenticated sessions, see their current state, and revoke stale or risky access without changing the user record.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
value={sessionQuery}
|
value={sessionQuery}
|
||||||
onChange={(event) => setSessionQuery(event.target.value)}
|
onChange={(event) => setSessionQuery(event.target.value)}
|
||||||
@@ -485,7 +476,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">User</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">User</span>
|
||||||
<select
|
<select
|
||||||
value={sessionUserFilter}
|
value={sessionUserFilter}
|
||||||
onChange={(event) => setSessionUserFilter(event.target.value)}
|
onChange={(event) => setSessionUserFilter(event.target.value)}
|
||||||
@@ -500,7 +491,7 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||||
<select
|
<select
|
||||||
value={sessionStatusFilter}
|
value={sessionStatusFilter}
|
||||||
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
|
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
|
||||||
@@ -513,7 +504,7 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Review</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Review</span>
|
||||||
<select
|
<select
|
||||||
value={sessionReviewFilter}
|
value={sessionReviewFilter}
|
||||||
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
|
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
|
||||||
|
|||||||
@@ -218,11 +218,11 @@ export function ShipmentDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment</p>
|
<p className="section-kicker">SHIPMENT</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{shipment.shipmentNumber}</h3>
|
<h3 className="module-title">{shipment.shipmentNumber}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} / {shipment.customerName}</p>
|
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} / {shipment.customerName}</p>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
<ShipmentStatusBadge status={shipment.status} />
|
<ShipmentStatusBadge status={shipment.status} />
|
||||||
@@ -249,11 +249,10 @@ export function ShipmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
<p className="section-kicker">QUICK ACTIONS</p>
|
||||||
<p className="mt-2 text-sm text-muted">Use inventory-backed picking before marking the shipment packed or shipped.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{shipmentStatusOptions.map((option) => (
|
{shipmentStatusOptions.map((option) => (
|
||||||
@@ -266,31 +265,30 @@ export function ShipmentDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-2 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Carrier</p>
|
<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>
|
<div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ordered Units</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ordered Units</p>
|
||||||
<div className="mt-2 text-base font-bold text-text">{totalOrderedQuantity}</div>
|
<div className="mt-2 text-base font-bold text-text">{totalOrderedQuantity}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Picked Units</p>
|
<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>
|
<div className="mt-2 text-base font-bold text-text">{totalPickedQuantity}</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
<article className="surface-panel-tight">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Packages</p>
|
<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>
|
<div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.3fr)_minmax(340px,0.9fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.3fr)_minmax(340px,0.9fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment Lines</p>
|
<p className="section-kicker">SHIPMENT LINES</p>
|
||||||
<p className="mt-2 text-sm text-muted">Track ordered, picked, and remaining quantity before shipment closeout.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 overflow-x-auto">
|
<div className="mt-5 overflow-x-auto">
|
||||||
@@ -326,8 +324,8 @@ export function ShipmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timing</p>
|
<p className="section-kicker">TIMING</p>
|
||||||
<dl className="mt-5 grid gap-3">
|
<dl className="mt-5 grid gap-3">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt>
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt>
|
||||||
@@ -350,16 +348,13 @@ export function ShipmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Pick And Issue From Stock</p>
|
<p className="section-kicker">PICK AND ISSUE FROM STOCK</p>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">
|
|
||||||
Posting a pick immediately creates an inventory issue transaction against the selected warehouse location and advances draft shipments into picking.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-xs text-muted">
|
<div className="rounded-[16px] border border-line/70 bg-page/60 px-2 py-2 text-xs text-muted">
|
||||||
Select the sales-order line, source location, and quantity you are physically picking.
|
Select line, location, and quantity.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
@@ -468,19 +463,18 @@ export function ShipmentDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Pick History</p>
|
<p className="section-kicker">PICK HISTORY</p>
|
||||||
<p className="mt-2 text-sm text-muted">Every pick here already issued stock from a specific inventory location.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shipment.picks.length === 0 ? (
|
{shipment.picks.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No shipment picks have been posted yet.
|
No shipment picks posted yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{shipment.picks.map((pick) => (
|
{shipment.picks.map((pick) => (
|
||||||
<div key={pick.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<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 className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -502,26 +496,25 @@ export function ShipmentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment Notes</p>
|
<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>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Related Shipments</p>
|
<p className="section-kicker">RELATED SHIPMENTS</p>
|
||||||
<p className="mt-2 text-sm text-muted">Other shipments already tied to this sales order.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link to={`/shipping/shipments/new?orderId=${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add another shipment</Link>
|
<Link to={`/shipping/shipments/new?orderId=${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add another shipment</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{relatedShipments.length === 0 ? (
|
{relatedShipments.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No additional shipments exist for this sales order.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No additional shipments.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{relatedShipments.map((related) => (
|
{relatedShipments.map((related) => (
|
||||||
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js";
|
import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
@@ -85,20 +85,24 @@ export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
navigate(mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Editor</p>
|
<p className="section-kicker">SHIPPING EDITOR</p>
|
||||||
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
|
<h3 className="module-title">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
|
||||||
</div>
|
</div>
|
||||||
<Link to={mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
<button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="space-y-3 surface-panel">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Sales Order</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Sales Order</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -38,12 +38,11 @@ export function ShipmentListPage() {
|
|||||||
}, [searchTerm, statusFilter, token]);
|
}, [searchTerm, statusFilter, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
|
<p className="section-kicker">SHIPPING</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Shipments</h3>
|
<h3 className="module-title">SHIPMENTS</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">Outbound shipment records tied to sales orders, carriers, and tracking details.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Link to="/shipping/shipments/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
<Link to="/shipping/shipments/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
@@ -51,7 +50,7 @@ export function ShipmentListPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
<div className="mt-4 grid gap-2.5 rounded-[16px] border border-line/70 bg-page/60 p-2.5 xl:grid-cols-[1.35fr_0.8fr]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
@@ -76,11 +75,11 @@ export function ShipmentListPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
<div className="mt-4 rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-sm text-muted">{status}</div>
|
||||||
{shipments.length === 0 ? (
|
{shipments.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No shipments have been added yet.</div>
|
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-page/60 px-4 py-7 text-center text-sm text-muted">No shipments have been added yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
<div className="mt-4 overflow-hidden rounded-[16px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/80 text-left text-muted">
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
ALTER TABLE "PurchaseOrder" ADD COLUMN "projectId" TEXT REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX "PurchaseOrder_projectId_issueDate_idx" ON "PurchaseOrder"("projectId", "issueDate");
|
||||||
|
|
||||||
|
UPDATE "WorkOrder"
|
||||||
|
SET "projectId" = (
|
||||||
|
SELECT "Project"."id"
|
||||||
|
FROM "Project"
|
||||||
|
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
|
||||||
|
ORDER BY "Project"."createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE "projectId" IS NULL
|
||||||
|
AND "salesOrderId" IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Project"
|
||||||
|
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE "PurchaseOrder"
|
||||||
|
SET "projectId" = (
|
||||||
|
SELECT "Project"."id"
|
||||||
|
FROM "PurchaseOrderLine"
|
||||||
|
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
|
||||||
|
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
|
||||||
|
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
|
||||||
|
ORDER BY "Project"."createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE "projectId" IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "PurchaseOrderLine"
|
||||||
|
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
|
||||||
|
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
|
||||||
|
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "WorkOrder" ADD COLUMN "holdReason" TEXT;
|
||||||
@@ -618,6 +618,7 @@ model Project {
|
|||||||
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
|
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
|
||||||
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||||
workOrders WorkOrder[]
|
workOrders WorkOrder[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
milestones ProjectMilestone[]
|
milestones ProjectMilestone[]
|
||||||
|
|
||||||
@@index([customerId, createdAt])
|
@@index([customerId, createdAt])
|
||||||
@@ -652,6 +653,7 @@ model WorkOrder {
|
|||||||
warehouseId String
|
warehouseId String
|
||||||
locationId String
|
locationId String
|
||||||
status String
|
status String
|
||||||
|
holdReason String?
|
||||||
quantity Int
|
quantity Int
|
||||||
completedQuantity Int @default(0)
|
completedQuantity Int @default(0)
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
@@ -864,6 +866,7 @@ model PurchaseOrder {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
documentNumber String @unique
|
documentNumber String @unique
|
||||||
vendorId String
|
vendorId String
|
||||||
|
projectId String?
|
||||||
status String
|
status String
|
||||||
issueDate DateTime
|
issueDate DateTime
|
||||||
taxPercent Float @default(0)
|
taxPercent Float @default(0)
|
||||||
@@ -872,10 +875,13 @@ model PurchaseOrder {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
lines PurchaseOrderLine[]
|
lines PurchaseOrderLine[]
|
||||||
receipts PurchaseReceipt[]
|
receipts PurchaseReceipt[]
|
||||||
revisions PurchaseOrderRevision[]
|
revisions PurchaseOrderRevision[]
|
||||||
capexEntries CapexEntry[]
|
capexEntries CapexEntry[]
|
||||||
|
|
||||||
|
@@index([projectId, issueDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PurchaseOrderLine {
|
model PurchaseOrderLine {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import puppeteer from "puppeteer";
|
import puppeteer, { PaperFormat } from "puppeteer";
|
||||||
|
|
||||||
import { env } from "../config/env.js";
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
export async function renderPdf(html: string) {
|
interface PdfOptions {
|
||||||
|
format?: PaperFormat;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
margin?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdf(html: string, options?: PdfOptions) {
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
|
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -14,7 +21,10 @@ export async function renderPdf(html: string) {
|
|||||||
await page.setContent(html, { waitUntil: "networkidle0" });
|
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = await page.pdf({
|
||||||
format: "A4",
|
format: options?.width || options?.height ? undefined : (options?.format || "A4"),
|
||||||
|
width: options?.width,
|
||||||
|
height: options?.height,
|
||||||
|
margin: options?.margin,
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
preferCSSPageSize: true,
|
preferCSSPageSize: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,29 +152,40 @@ function buildShippingLabelPdf(options: {
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
@page { size: 4in 6in; margin: 8mm; }
|
@page { size: 4in 6in; margin: 0; }
|
||||||
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: calc(6in - 16mm); box-sizing: border-box; }
|
html, body { width: 4in; min-width: 4in; max-width: 4in; height: 6in; min-height: 6in; max-height: 6in; margin: 0; padding: 0; overflow: hidden; background: white; }
|
||||||
.row { display: flex; justify-content: space-between; gap: 12px; }
|
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 10px; line-height: 1.2; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.page { width: 4in; height: 6in; padding: 0.14in; overflow: hidden; page-break-after: avoid; break-after: avoid-page; }
|
||||||
|
.label { width: 100%; height: 100%; border: 2px solid #111827; border-radius: 10px; padding: 0.11in; display: flex; flex-direction: column; gap: 0.09in; overflow: hidden; }
|
||||||
|
.row { display: flex; justify-content: space-between; gap: 0.09in; }
|
||||||
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
|
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
|
||||||
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; }
|
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 0.09in; }
|
||||||
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
|
.brand-row { align-items: flex-start; }
|
||||||
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
|
.brand-company { flex: 1; min-width: 0; padding-right: 0.06in; }
|
||||||
.stack { display: flex; flex-direction: column; gap: 4px; }
|
.brand h1 { margin: 0; font-size: 16px; line-height: 1.05; color: ${company.theme.primaryColor}; overflow-wrap: anywhere; }
|
||||||
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
|
.shipment-number { width: 1.25in; flex: 0 0 1.25in; text-align: right; }
|
||||||
|
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 0.08in; min-width: 0; }
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.barcode { border: 2px solid #111827; border-radius: 8px; padding: 0.08in; text-align: center; font-family: monospace; font-size: 16px; line-height: 1; letter-spacing: 0.15em; }
|
||||||
.strong { font-weight: 700; }
|
.strong { font-weight: 700; }
|
||||||
.big { font-size: 16px; font-weight: 700; }
|
.big { font-size: 15px; line-height: 1.05; font-weight: 700; }
|
||||||
|
.footer { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
|
||||||
|
.reference-text { margin-top: 6px; overflow-wrap: anywhere; word-break: break-word; }
|
||||||
|
.block > div[style="margin-top:6px;"] { overflow-wrap: anywhere; word-break: break-word; }
|
||||||
|
div[style="text-align:center; font-size:10px; color:#4b5563;"] { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="page">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="row">
|
<div class="row brand-row">
|
||||||
<div>
|
<div class="brand-company">
|
||||||
<div class="muted">From</div>
|
<div class="muted">From</div>
|
||||||
<h1>${escapeHtml(company.companyName)}</h1>
|
<h1>${escapeHtml(company.companyName)}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;">
|
<div class="shipment-number">
|
||||||
<div class="muted">Shipment</div>
|
<div class="muted">Shipment</div>
|
||||||
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
|
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +228,7 @@ function buildShippingLabelPdf(options: {
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`, { width: "4in", height: "6in", margin: { top: "0", right: "0", bottom: "0", left: "0" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBillOfLadingPdf(options: {
|
function buildBillOfLadingPdf(options: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
GanttLinkDto,
|
GanttLinkDto,
|
||||||
GanttTaskDto,
|
GanttTaskDto,
|
||||||
PlanningReadinessState,
|
PlanningReadinessState,
|
||||||
|
PlanningStationDayLoadDto,
|
||||||
PlanningStationLoadDto,
|
PlanningStationLoadDto,
|
||||||
PlanningTaskActionDto,
|
PlanningTaskActionDto,
|
||||||
PlanningTimelineDto,
|
PlanningTimelineDto,
|
||||||
@@ -94,6 +95,15 @@ type StationAccumulator = {
|
|||||||
workingDays: number[];
|
workingDays: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StationDayAccumulator = {
|
||||||
|
stationId: string;
|
||||||
|
dateKey: string;
|
||||||
|
plannedMinutes: number;
|
||||||
|
actualMinutes: number;
|
||||||
|
operationCount: number;
|
||||||
|
capacityMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
function clampProgress(value: number) {
|
function clampProgress(value: number) {
|
||||||
return Math.max(0, Math.min(100, Math.round(value)));
|
return Math.max(0, Math.min(100, Math.round(value)));
|
||||||
}
|
}
|
||||||
@@ -199,6 +209,23 @@ function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStationDayLoad(record: StationDayAccumulator): PlanningStationDayLoadDto {
|
||||||
|
const capacityMinutes = Math.max(record.capacityMinutes, 1);
|
||||||
|
const utilizationPercent = Math.round((record.plannedMinutes / capacityMinutes) * 100);
|
||||||
|
const actualUtilizationPercent = Math.round((record.actualMinutes / capacityMinutes) * 100);
|
||||||
|
return {
|
||||||
|
stationId: record.stationId,
|
||||||
|
dateKey: record.dateKey,
|
||||||
|
plannedMinutes: record.plannedMinutes,
|
||||||
|
actualMinutes: record.actualMinutes,
|
||||||
|
capacityMinutes,
|
||||||
|
utilizationPercent,
|
||||||
|
actualUtilizationPercent,
|
||||||
|
operationCount: record.operationCount,
|
||||||
|
overloaded: utilizationPercent > 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildProjectTask(
|
function buildProjectTask(
|
||||||
project: PlanningProjectRecord,
|
project: PlanningProjectRecord,
|
||||||
projectWorkOrders: PlanningWorkOrderRecord[],
|
projectWorkOrders: PlanningWorkOrderRecord[],
|
||||||
@@ -492,6 +519,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stationAccumulators = new Map<string, StationAccumulator>();
|
const stationAccumulators = new Map<string, StationAccumulator>();
|
||||||
|
const stationDayAccumulators = new Map<string, StationDayAccumulator>();
|
||||||
for (const workOrder of openWorkOrders) {
|
for (const workOrder of openWorkOrders) {
|
||||||
const insight = workOrderInsights.get(workOrder.id);
|
const insight = workOrderInsights.get(workOrder.id);
|
||||||
for (const operation of workOrder.operations) {
|
for (const operation of workOrder.operations) {
|
||||||
@@ -525,7 +553,22 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
current.lateCount += 1;
|
current.lateCount += 1;
|
||||||
}
|
}
|
||||||
for (let cursor = startOfDay(operation.plannedStart).getTime(); cursor <= startOfDay(operation.plannedEnd).getTime(); cursor += DAY_MS) {
|
for (let cursor = startOfDay(operation.plannedStart).getTime(); cursor <= startOfDay(operation.plannedEnd).getTime(); cursor += DAY_MS) {
|
||||||
current.dayKeys.add(dateKey(new Date(cursor)));
|
const currentDate = new Date(cursor);
|
||||||
|
const currentDateKey = dateKey(currentDate);
|
||||||
|
current.dayKeys.add(currentDateKey);
|
||||||
|
const dayAccumulatorKey = `${operation.station.id}:${currentDateKey}`;
|
||||||
|
const dayAccumulator = stationDayAccumulators.get(dayAccumulatorKey) ?? {
|
||||||
|
stationId: operation.station.id,
|
||||||
|
dateKey: currentDateKey,
|
||||||
|
plannedMinutes: 0,
|
||||||
|
actualMinutes: 0,
|
||||||
|
operationCount: 0,
|
||||||
|
capacityMinutes: Math.max(operation.station.dailyCapacityMinutes, 60) * Math.max(operation.station.parallelCapacity, 1),
|
||||||
|
};
|
||||||
|
dayAccumulator.plannedMinutes += operation.plannedMinutes;
|
||||||
|
dayAccumulator.actualMinutes += operation.actualMinutes;
|
||||||
|
dayAccumulator.operationCount += 1;
|
||||||
|
stationDayAccumulators.set(dayAccumulatorKey, dayAccumulator);
|
||||||
}
|
}
|
||||||
stationAccumulators.set(operation.station.id, current);
|
stationAccumulators.set(operation.station.id, current);
|
||||||
}
|
}
|
||||||
@@ -537,6 +580,14 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
}
|
}
|
||||||
return left.stationCode.localeCompare(right.stationCode);
|
return left.stationCode.localeCompare(right.stationCode);
|
||||||
});
|
});
|
||||||
|
const stationDayLoads = [...stationDayAccumulators.values()]
|
||||||
|
.map(createStationDayLoad)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.dateKey !== right.dateKey) {
|
||||||
|
return left.dateKey.localeCompare(right.dateKey);
|
||||||
|
}
|
||||||
|
return left.stationId.localeCompare(right.stationId);
|
||||||
|
});
|
||||||
const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load]));
|
const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load]));
|
||||||
|
|
||||||
const tasks: GanttTaskDto[] = [];
|
const tasks: GanttTaskDto[] = [];
|
||||||
@@ -863,5 +914,6 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
})
|
})
|
||||||
.slice(0, 12),
|
.slice(0, 12),
|
||||||
stationLoads,
|
stationLoads,
|
||||||
|
stationDayLoads,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const workOrderFiltersSchema = z.object({
|
|||||||
|
|
||||||
const statusUpdateSchema = z.object({
|
const statusUpdateSchema = z.object({
|
||||||
status: z.enum(workOrderStatuses),
|
status: z.enum(workOrderStatuses),
|
||||||
|
reason: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const materialIssueSchema = z.object({
|
const materialIssueSchema = z.object({
|
||||||
@@ -215,7 +216,7 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
|
|||||||
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
|
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id);
|
const result = await updateWorkOrderStatus(workOrderId, parsed.data, request.authUser?.id);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
WorkOrderOperationTimerInput,
|
WorkOrderOperationTimerInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
|
WorkOrderStatusUpdateInput,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ type WorkOrderRecord = {
|
|||||||
id: string;
|
id: string;
|
||||||
workOrderNumber: string;
|
workOrderNumber: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
holdReason: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
completedQuantity: number;
|
completedQuantity: number;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
@@ -390,6 +392,7 @@ function mapDetail(
|
|||||||
return {
|
return {
|
||||||
...mapSummary(record),
|
...mapSummary(record),
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
|
holdReason: record.holdReason,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
itemType: record.item.type,
|
itemType: record.item.type,
|
||||||
itemUnitOfMeasure: record.item.unitOfMeasure,
|
itemUnitOfMeasure: record.item.unitOfMeasure,
|
||||||
@@ -868,15 +871,18 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|||||||
return { ok: false as const, reason: "Build item must have at least one station operation before a work order can be created." };
|
return { ok: false as const, reason: "Build item must have at least one station operation before a work order can be created." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let projectSalesOrderId: string | null = null;
|
||||||
if (payload.projectId) {
|
if (payload.projectId) {
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { id: payload.projectId },
|
where: { id: payload.projectId },
|
||||||
select: { id: true },
|
select: { id: true, salesOrderId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return { ok: false as const, reason: "Linked project was not found." };
|
return { ok: false as const, reason: "Linked project was not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
projectSalesOrderId = project.salesOrderId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.salesOrderId) {
|
if (payload.salesOrderId) {
|
||||||
@@ -888,6 +894,10 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|||||||
if (!salesOrder) {
|
if (!salesOrder) {
|
||||||
return { ok: false as const, reason: "Linked sales order was not found." };
|
return { ok: false as const, reason: "Linked sales order was not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (projectSalesOrderId && projectSalesOrderId !== payload.salesOrderId) {
|
||||||
|
return { ok: false as const, reason: "Linked project does not match the selected sales order." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.salesOrderLineId) {
|
if (payload.salesOrderLineId) {
|
||||||
@@ -928,6 +938,28 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deriveProjectIdForWorkOrder(payload: WorkOrderInput) {
|
||||||
|
if (payload.projectId) {
|
||||||
|
return payload.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.salesOrderId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
salesOrderId: payload.salesOrderId,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return project?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
|
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
|
||||||
const items = await prisma.inventoryItem.findMany({
|
const items = await prisma.inventoryItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -1158,13 +1190,14 @@ export async function createWorkOrder(payload: WorkOrderInput, actorId?: string
|
|||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return { ok: false as const, reason: validated.reason };
|
return { ok: false as const, reason: validated.reason };
|
||||||
}
|
}
|
||||||
|
const derivedProjectId = await deriveProjectIdForWorkOrder(payload);
|
||||||
|
|
||||||
const workOrderNumber = await nextWorkOrderNumber();
|
const workOrderNumber = await nextWorkOrderNumber();
|
||||||
const created = await workOrderModel.create({
|
const created = await workOrderModel.create({
|
||||||
data: {
|
data: {
|
||||||
workOrderNumber,
|
workOrderNumber,
|
||||||
itemId: payload.itemId,
|
itemId: payload.itemId,
|
||||||
projectId: payload.projectId,
|
projectId: derivedProjectId,
|
||||||
salesOrderId: payload.salesOrderId,
|
salesOrderId: payload.salesOrderId,
|
||||||
salesOrderLineId: payload.salesOrderLineId,
|
salesOrderLineId: payload.salesOrderLineId,
|
||||||
warehouseId: payload.warehouseId,
|
warehouseId: payload.warehouseId,
|
||||||
@@ -1223,12 +1256,13 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
|
|||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return { ok: false as const, reason: validated.reason };
|
return { ok: false as const, reason: validated.reason };
|
||||||
}
|
}
|
||||||
|
const derivedProjectId = await deriveProjectIdForWorkOrder(payload);
|
||||||
|
|
||||||
await workOrderModel.update({
|
await workOrderModel.update({
|
||||||
where: { id: workOrderId },
|
where: { id: workOrderId },
|
||||||
data: {
|
data: {
|
||||||
itemId: payload.itemId,
|
itemId: payload.itemId,
|
||||||
projectId: payload.projectId,
|
projectId: derivedProjectId,
|
||||||
salesOrderId: payload.salesOrderId,
|
salesOrderId: payload.salesOrderId,
|
||||||
salesOrderLineId: payload.salesOrderLineId,
|
salesOrderLineId: payload.salesOrderLineId,
|
||||||
warehouseId: payload.warehouseId,
|
warehouseId: payload.warehouseId,
|
||||||
@@ -1263,12 +1297,18 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
|
|||||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus, actorId?: string | null) {
|
export async function updateWorkOrderStatus(
|
||||||
|
workOrderId: string,
|
||||||
|
payload: WorkOrderStatusUpdateInput,
|
||||||
|
actorId?: string | null
|
||||||
|
) {
|
||||||
const existing = await workOrderModel.findUnique({
|
const existing = await workOrderModel.findUnique({
|
||||||
where: { id: workOrderId },
|
where: { id: workOrderId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
workOrderNumber: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
holdReason: true,
|
||||||
quantity: true,
|
quantity: true,
|
||||||
completedQuantity: true,
|
completedQuantity: true,
|
||||||
},
|
},
|
||||||
@@ -1278,18 +1318,24 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
|
|||||||
return { ok: false as const, reason: "Work order was not found." };
|
return { ok: false as const, reason: "Work order was not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.status === "COMPLETE" && status !== "COMPLETE") {
|
if (existing.status === "COMPLETE" && payload.status !== "COMPLETE") {
|
||||||
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
|
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
|
if (payload.status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
|
||||||
return { ok: false as const, reason: "Use the completion action to finish a work order." };
|
return { ok: false as const, reason: "Use the completion action to finish a work order." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextHoldReason = payload.reason?.trim() ?? "";
|
||||||
|
if (payload.status === "ON_HOLD" && nextHoldReason.length === 0) {
|
||||||
|
return { ok: false as const, reason: "An On Hold reason is required before the work order can be paused." };
|
||||||
|
}
|
||||||
|
|
||||||
await workOrderModel.update({
|
await workOrderModel.update({
|
||||||
where: { id: workOrderId },
|
where: { id: workOrderId },
|
||||||
data: {
|
data: {
|
||||||
status,
|
status: payload.status,
|
||||||
|
holdReason: payload.status === "ON_HOLD" ? nextHoldReason : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1302,10 +1348,12 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
|
|||||||
entityType: "work-order",
|
entityType: "work-order",
|
||||||
entityId: workOrderId,
|
entityId: workOrderId,
|
||||||
action: "status.updated",
|
action: "status.updated",
|
||||||
summary: `Updated work order ${workOrder.workOrderNumber} to ${status}.`,
|
summary: `Updated work order ${workOrder.workOrderNumber} to ${payload.status}.`,
|
||||||
metadata: {
|
metadata: {
|
||||||
workOrderNumber: workOrder.workOrderNumber,
|
workOrderNumber: workOrder.workOrderNumber,
|
||||||
status,
|
previousStatus: existing.status,
|
||||||
|
status: payload.status,
|
||||||
|
holdReason: payload.status === "ON_HOLD" ? nextHoldReason : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
listProjectQuoteOptions,
|
listProjectQuoteOptions,
|
||||||
listProjectShipmentOptions,
|
listProjectShipmentOptions,
|
||||||
updateProject,
|
updateProject,
|
||||||
|
updateProjectMilestoneStatus,
|
||||||
} from "./service.js";
|
} from "./service.js";
|
||||||
|
|
||||||
const projectSchema = z.object({
|
const projectSchema = z.object({
|
||||||
@@ -51,6 +52,10 @@ const projectOptionQuerySchema = z.object({
|
|||||||
customerId: z.string().optional(),
|
customerId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const milestoneStatusSchema = z.object({
|
||||||
|
status: z.enum(projectMilestoneStatuses),
|
||||||
|
});
|
||||||
|
|
||||||
function getRouteParam(value: unknown) {
|
function getRouteParam(value: unknown) {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
@@ -147,3 +152,23 @@ projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]
|
|||||||
|
|
||||||
return ok(response, result.project);
|
return ok(response, result.project);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
projectsRouter.patch("/:projectId/milestones/:milestoneId/status", requirePermissions([permissions.projectsWrite]), async (request, response) => {
|
||||||
|
const projectId = getRouteParam(request.params.projectId);
|
||||||
|
const milestoneId = getRouteParam(request.params.milestoneId);
|
||||||
|
if (!projectId || !milestoneId) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Project or milestone id is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = milestoneStatusSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Project milestone status payload is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateProjectMilestoneStatus(projectId, milestoneId, parsed.data, request.authUser?.id);
|
||||||
|
if (!result.ok) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(response, result.project);
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
ProjectInput,
|
ProjectInput,
|
||||||
ProjectMilestoneDto,
|
ProjectMilestoneDto,
|
||||||
ProjectMilestoneInput,
|
ProjectMilestoneInput,
|
||||||
|
ProjectMilestoneStatus,
|
||||||
|
ProjectMilestoneStatusUpdateInput,
|
||||||
ProjectOwnerOptionDto,
|
ProjectOwnerOptionDto,
|
||||||
ProjectPriority,
|
ProjectPriority,
|
||||||
ProjectRollupDto,
|
ProjectRollupDto,
|
||||||
@@ -1266,3 +1268,60 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
|
|||||||
}
|
}
|
||||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProjectMilestoneStatus(
|
||||||
|
projectId: string,
|
||||||
|
milestoneId: string,
|
||||||
|
payload: ProjectMilestoneStatusUpdateInput,
|
||||||
|
actorId?: string | null
|
||||||
|
) {
|
||||||
|
const existing = await prisma.projectMilestone.findUnique({
|
||||||
|
where: { id: milestoneId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
completedAt: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectNumber: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing || existing.projectId !== projectId) {
|
||||||
|
return { ok: false as const, reason: "Project milestone was not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = payload.status as ProjectMilestoneStatus;
|
||||||
|
await prisma.projectMilestone.update({
|
||||||
|
where: { id: milestoneId },
|
||||||
|
data: {
|
||||||
|
status: nextStatus,
|
||||||
|
completedAt: nextStatus === "COMPLETE" ? existing.completedAt ?? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await getProjectById(projectId);
|
||||||
|
if (project) {
|
||||||
|
await logAuditEvent({
|
||||||
|
actorId,
|
||||||
|
entityType: "project",
|
||||||
|
entityId: projectId,
|
||||||
|
action: "milestone.status.updated",
|
||||||
|
summary: `Updated milestone ${existing.title} on ${existing.project.projectNumber} to ${nextStatus}.`,
|
||||||
|
metadata: {
|
||||||
|
projectNumber: existing.project.projectNumber,
|
||||||
|
milestoneId,
|
||||||
|
milestoneTitle: existing.title,
|
||||||
|
previousStatus: existing.status,
|
||||||
|
status: nextStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const purchaseLineSchema = z.object({
|
|||||||
|
|
||||||
const purchaseOrderSchema = z.object({
|
const purchaseOrderSchema = z.object({
|
||||||
vendorId: z.string().trim().min(1),
|
vendorId: z.string().trim().min(1),
|
||||||
|
projectId: z.string().trim().min(1).nullable().optional(),
|
||||||
status: z.enum(purchaseOrderStatuses),
|
status: z.enum(purchaseOrderStatuses),
|
||||||
issueDate: z.string().datetime(),
|
issueDate: z.string().datetime(),
|
||||||
taxPercent: z.number().min(0).max(100),
|
taxPercent: z.number().min(0).max(100),
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ type PurchaseOrderRevisionRecord = {
|
|||||||
type PurchaseOrderRecord = {
|
type PurchaseOrderRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
documentNumber: string;
|
documentNumber: string;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
projectNumber: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
status: string;
|
status: string;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
taxPercent: number;
|
taxPercent: number;
|
||||||
@@ -145,6 +150,17 @@ type PurchaseOrderRecord = {
|
|||||||
revisions: PurchaseOrderRevisionRecord[];
|
revisions: PurchaseOrderRevisionRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NormalizedPurchaseLine = {
|
||||||
|
itemId: string;
|
||||||
|
salesOrderId: string | null;
|
||||||
|
salesOrderLineId: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitOfMeasure: PurchaseLineInput["unitOfMeasure"];
|
||||||
|
unitCost: number;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
function roundMoney(value: number) {
|
function roundMoney(value: number) {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
@@ -322,6 +338,63 @@ async function validateLines(lines: PurchaseLineInput[]) {
|
|||||||
return { ok: true as const, lines: normalized };
|
return { ok: true as const, lines: normalized };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolvePurchaseOrderProjectId(projectId: string | null | undefined, lines: NormalizedPurchaseLine[]) {
|
||||||
|
let explicitProjectId = projectId ?? null;
|
||||||
|
|
||||||
|
if (explicitProjectId) {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: explicitProjectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
salesOrderId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
return { ok: false as const, reason: "Linked project was not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
|
||||||
|
if (linkedSalesOrderIds.length > 0 && project.salesOrderId && linkedSalesOrderIds.some((salesOrderId) => salesOrderId !== project.salesOrderId)) {
|
||||||
|
return { ok: false as const, reason: "Linked project does not match the sales-order demand attached to this purchase order." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true as const, projectId: project.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
|
||||||
|
if (linkedSalesOrderIds.length === 0) {
|
||||||
|
return { ok: true as const, projectId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProjects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
salesOrderId: {
|
||||||
|
in: linkedSalesOrderIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
salesOrderId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectBySalesOrderId = new Map<string, string>();
|
||||||
|
for (const project of matchingProjects) {
|
||||||
|
if (project.salesOrderId && !projectBySalesOrderId.has(project.salesOrderId)) {
|
||||||
|
projectBySalesOrderId.set(project.salesOrderId, project.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivedProjectIds = [...new Set(linkedSalesOrderIds.map((salesOrderId) => projectBySalesOrderId.get(salesOrderId)).filter((value): value is string => Boolean(value)))];
|
||||||
|
if (derivedProjectIds.length > 1) {
|
||||||
|
return { ok: false as const, reason: "Purchase orders can only auto-link to one project. Split the document or set the project intentionally." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true as const, projectId: derivedProjectIds[0] ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
||||||
const receivedByLineId = new Map<string, number>();
|
const receivedByLineId = new Map<string, number>();
|
||||||
|
|
||||||
@@ -370,6 +443,9 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
|||||||
documentNumber: record.documentNumber,
|
documentNumber: record.documentNumber,
|
||||||
vendorId: record.vendor.id,
|
vendorId: record.vendor.id,
|
||||||
vendorName: record.vendor.name,
|
vendorName: record.vendor.name,
|
||||||
|
projectId: record.project?.id ?? null,
|
||||||
|
projectNumber: record.project?.projectNumber ?? null,
|
||||||
|
projectName: record.project?.name ?? null,
|
||||||
vendorEmail: record.vendor.email,
|
vendorEmail: record.vendor.email,
|
||||||
paymentTerms: record.vendor.paymentTerms,
|
paymentTerms: record.vendor.paymentTerms,
|
||||||
currencyCode: record.vendor.currencyCode,
|
currencyCode: record.vendor.currencyCode,
|
||||||
@@ -487,6 +563,13 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
|
|||||||
currencyCode: true,
|
currencyCode: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectNumber: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
lines: {
|
lines: {
|
||||||
include: {
|
include: {
|
||||||
item: {
|
item: {
|
||||||
@@ -715,6 +798,9 @@ export async function listPurchaseOrders(filters: { q?: string; status?: Purchas
|
|||||||
documentNumber: detail.documentNumber,
|
documentNumber: detail.documentNumber,
|
||||||
vendorId: detail.vendorId,
|
vendorId: detail.vendorId,
|
||||||
vendorName: detail.vendorName,
|
vendorName: detail.vendorName,
|
||||||
|
projectId: detail.projectId,
|
||||||
|
projectNumber: detail.projectNumber,
|
||||||
|
projectName: detail.projectName,
|
||||||
status: detail.status,
|
status: detail.status,
|
||||||
subtotal: detail.subtotal,
|
subtotal: detail.subtotal,
|
||||||
taxPercent: detail.taxPercent,
|
taxPercent: detail.taxPercent,
|
||||||
@@ -762,6 +848,11 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
|
|||||||
return { ok: false as const, reason: validatedLines.reason };
|
return { ok: false as const, reason: validatedLines.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
|
||||||
|
if (!resolvedProject.ok) {
|
||||||
|
return { ok: false as const, reason: resolvedProject.reason };
|
||||||
|
}
|
||||||
|
|
||||||
const vendor = await prisma.vendor.findUnique({
|
const vendor = await prisma.vendor.findUnique({
|
||||||
where: { id: payload.vendorId },
|
where: { id: payload.vendorId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -777,6 +868,7 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
|
|||||||
data: {
|
data: {
|
||||||
documentNumber,
|
documentNumber,
|
||||||
vendorId: payload.vendorId,
|
vendorId: payload.vendorId,
|
||||||
|
projectId: resolvedProject.projectId,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
issueDate: new Date(payload.issueDate),
|
issueDate: new Date(payload.issueDate),
|
||||||
taxPercent: payload.taxPercent,
|
taxPercent: payload.taxPercent,
|
||||||
@@ -824,6 +916,11 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
|
|||||||
return { ok: false as const, reason: validatedLines.reason };
|
return { ok: false as const, reason: validatedLines.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
|
||||||
|
if (!resolvedProject.ok) {
|
||||||
|
return { ok: false as const, reason: resolvedProject.reason };
|
||||||
|
}
|
||||||
|
|
||||||
const vendor = await prisma.vendor.findUnique({
|
const vendor = await prisma.vendor.findUnique({
|
||||||
where: { id: payload.vendorId },
|
where: { id: payload.vendorId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -837,6 +934,7 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
|
|||||||
where: { id: documentId },
|
where: { id: documentId },
|
||||||
data: {
|
data: {
|
||||||
vendorId: payload.vendorId,
|
vendorId: payload.vendorId,
|
||||||
|
projectId: resolvedProject.projectId,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
issueDate: new Date(payload.issueDate),
|
issueDate: new Date(payload.issueDate),
|
||||||
taxPercent: payload.taxPercent,
|
taxPercent: payload.taxPercent,
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ type SalesDocumentRecord = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
projects: Array<{
|
||||||
|
id: string;
|
||||||
|
projectNumber: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
revisions: RevisionRecord[];
|
revisions: RevisionRecord[];
|
||||||
lines: SalesLineRecord[];
|
lines: SalesLineRecord[];
|
||||||
};
|
};
|
||||||
@@ -279,6 +285,9 @@ function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
|
|||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
updatedAt: record.updatedAt.toISOString(),
|
updatedAt: record.updatedAt.toISOString(),
|
||||||
|
linkedProjectId: record.projects[0]?.id ?? null,
|
||||||
|
linkedProjectNumber: record.projects[0]?.projectNumber ?? null,
|
||||||
|
linkedProjectName: record.projects[0]?.name ?? null,
|
||||||
lineCount: lines.length,
|
lineCount: lines.length,
|
||||||
lines,
|
lines,
|
||||||
revisions,
|
revisions,
|
||||||
@@ -383,6 +392,15 @@ function buildInclude() {
|
|||||||
lastName: true,
|
lastName: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projects: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectNumber: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" as const }],
|
||||||
|
},
|
||||||
revisions: {
|
revisions: {
|
||||||
include: {
|
include: {
|
||||||
createdBy: {
|
createdBy: {
|
||||||
@@ -799,6 +817,16 @@ export async function convertQuoteToSalesOrder(quoteId: string, userId?: string)
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.project.updateMany({
|
||||||
|
where: {
|
||||||
|
salesQuoteId: quoteId,
|
||||||
|
salesOrderId: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
salesOrderId: created.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return created.id;
|
return created.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,10 +95,23 @@ export interface PlanningStationLoadDto {
|
|||||||
lateCount: number;
|
lateCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlanningStationDayLoadDto {
|
||||||
|
stationId: string;
|
||||||
|
dateKey: string;
|
||||||
|
plannedMinutes: number;
|
||||||
|
actualMinutes: number;
|
||||||
|
capacityMinutes: number;
|
||||||
|
utilizationPercent: number;
|
||||||
|
actualUtilizationPercent: number;
|
||||||
|
operationCount: number;
|
||||||
|
overloaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlanningTimelineDto {
|
export interface PlanningTimelineDto {
|
||||||
tasks: GanttTaskDto[];
|
tasks: GanttTaskDto[];
|
||||||
links: GanttLinkDto[];
|
links: GanttLinkDto[];
|
||||||
summary: PlanningSummaryDto;
|
summary: PlanningSummaryDto;
|
||||||
exceptions: PlanningExceptionDto[];
|
exceptions: PlanningExceptionDto[];
|
||||||
stationLoads: PlanningStationLoadDto[];
|
stationLoads: PlanningStationLoadDto[];
|
||||||
|
stationDayLoads: PlanningStationDayLoadDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export interface WorkOrderCompletionDto {
|
|||||||
|
|
||||||
export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
|
export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
|
||||||
notes: string;
|
notes: string;
|
||||||
|
holdReason: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
itemType: string;
|
itemType: string;
|
||||||
itemUnitOfMeasure: string;
|
itemUnitOfMeasure: string;
|
||||||
@@ -218,3 +219,8 @@ export interface WorkOrderOperationTimerInput {
|
|||||||
action: "START" | "STOP";
|
action: "START" | "STOP";
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderStatusUpdateInput {
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ export interface ProjectMilestoneInput {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectMilestoneStatusUpdateInput {
|
||||||
|
status: ProjectMilestoneStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectDetailDto extends ProjectSummaryDto {
|
export interface ProjectDetailDto extends ProjectSummaryDto {
|
||||||
notes: string;
|
notes: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export interface PurchaseOrderSummaryDto {
|
|||||||
documentNumber: string;
|
documentNumber: string;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
vendorName: string;
|
vendorName: string;
|
||||||
|
projectId: string | null;
|
||||||
|
projectNumber: string | null;
|
||||||
|
projectName: string | null;
|
||||||
status: PurchaseOrderStatus;
|
status: PurchaseOrderStatus;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
taxPercent: number;
|
taxPercent: number;
|
||||||
@@ -70,6 +73,7 @@ export interface PurchaseOrderDetailDto extends PurchaseOrderSummaryDto {
|
|||||||
|
|
||||||
export interface PurchaseOrderInput {
|
export interface PurchaseOrderInput {
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
|
projectId?: string | null;
|
||||||
status: PurchaseOrderStatus;
|
status: PurchaseOrderStatus;
|
||||||
issueDate: string;
|
issueDate: string;
|
||||||
taxPercent: number;
|
taxPercent: number;
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
|
|||||||
notes: string;
|
notes: string;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
linkedProjectId: string | null;
|
||||||
|
linkedProjectNumber: string | null;
|
||||||
|
linkedProjectName: string | null;
|
||||||
lines: SalesLineDto[];
|
lines: SalesLineDto[];
|
||||||
revisions: SalesDocumentRevisionDto[];
|
revisions: SalesDocumentRevisionDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
96
test-puppeteer.js
Normal file
96
test-puppeteer.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
@page { size: 4in 6in; margin: 0; }
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { width: 4in; height: 6in; margin: 0; padding: 0.5in; overflow: hidden; }
|
||||||
|
body { font-family: Arial, sans-serif; color: #111827; font-size: 11px; }
|
||||||
|
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; height: 100%; overflow: hidden; }
|
||||||
|
.row { display: flex; justify-content: space-between; gap: 12px; }
|
||||||
|
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
|
||||||
|
.brand { border-bottom: 2px solid #ed8936; padding-bottom: 10px; }
|
||||||
|
.brand h1 { margin: 0; font-size: 18px; color: #ed8936; }
|
||||||
|
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
|
||||||
|
.strong { font-weight: 700; }
|
||||||
|
.big { font-size: 16px; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="label">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<div class="muted">From</div>
|
||||||
|
<h1>Message Point Media</h1>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div class="muted">Shipment</div>
|
||||||
|
<div class="big">SHP-00003</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<div class="muted">Ship To</div>
|
||||||
|
<div class="stack" style="margin-top:8px;">
|
||||||
|
<div class="strong">Northwind Fabrication</div>
|
||||||
|
<div>42 Assembly Ave</div>
|
||||||
|
<div>Milwaukee, WI 53202</div>
|
||||||
|
<div>USA</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="block" style="flex:1;">
|
||||||
|
<div class="muted">Service</div>
|
||||||
|
<div class="big" style="margin-top:6px;">GROUND</div>
|
||||||
|
</div>
|
||||||
|
<div class="block" style="width:90px;">
|
||||||
|
<div class="muted">Pkgs</div>
|
||||||
|
<div class="big" style="margin-top:6px;">1</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="block" style="flex:1;">
|
||||||
|
<div class="muted">Sales Order</div>
|
||||||
|
<div class="strong" style="margin-top:6px;">SO-00002</div>
|
||||||
|
</div>
|
||||||
|
<div class="block" style="width:110px;">
|
||||||
|
<div class="muted">Ship Date</div>
|
||||||
|
<div class="strong" style="margin-top:6px;">N/A</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<div class="muted">Reference</div>
|
||||||
|
<div style="margin-top:6px;">FG-CTRL-BASE · Control Base Assembly</div>
|
||||||
|
</div>
|
||||||
|
<div class="barcode">
|
||||||
|
*SHP-00003*
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; font-size:10px; color:#4b5563;">Carrier pending · Tracking pending</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const browser = await puppeteer.launch();
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
});
|
||||||
|
fs.writeFileSync('/tmp/test-label.pdf', pdf);
|
||||||
|
console.log("PDF generated at /tmp/test-label.pdf");
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
71
usage_guide.md
Normal file
71
usage_guide.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# CODEXIUM: End-to-End Workflow Guide
|
||||||
|
|
||||||
|
This guide walks through the core operational workflow in CODEXIUM, starting from capturing a customer request in a Quote, all the way to shipping the final product.
|
||||||
|
|
||||||
|
## 1. Create a Quote
|
||||||
|
The process begins when a customer requests pricing.
|
||||||
|
|
||||||
|
1. Navigate to the **Quotes** module.
|
||||||
|
2. Click to create a **New Quote**.
|
||||||
|
3. Select the Customer using the searchable lookup.
|
||||||
|
4. Add **Quote Lines** by searching for the requested Inventory SKUs. The default price from the item master will automatically populate.
|
||||||
|
5. Add any necessary discounts, freight, and notes.
|
||||||
|
6. The Quote can go through an approval process. Once the customer accepts the terms, proceed to the next step.
|
||||||
|
|
||||||
|
## 2. Convert to Sales Order
|
||||||
|
Once the Quote is accepted, it becomes firm demand.
|
||||||
|
|
||||||
|
1. Open the approved **Quote**.
|
||||||
|
2. Use the action menu to **Convert to Sales Order**.
|
||||||
|
3. This creates a new Sales Order record, carrying over all lines, pricing, and customer information.
|
||||||
|
4. The Sales Order is now the authoritative commercial record for this demand.
|
||||||
|
|
||||||
|
## 3. Create a Project
|
||||||
|
For complex or long-running deliveries, create a Project to track the execution.
|
||||||
|
|
||||||
|
1. Navigate to the **Projects** module.
|
||||||
|
2. Click **New Project** and select the same Customer.
|
||||||
|
3. Define the project scope, priority, due dates, and owner.
|
||||||
|
4. Set up high-level **Milestones** to track progress on deliverables.
|
||||||
|
|
||||||
|
## 4. Assign Quote and Sales Order to Project
|
||||||
|
Link the commercial documents to the execution tracker.
|
||||||
|
|
||||||
|
1. Open the newly created **Project**.
|
||||||
|
2. In the project details, link the original **Quote** and the active **Sales Order**.
|
||||||
|
3. *Note: You can also link the Project from within the Quote and Sales Order detail pages. This reverse-link ensures that quote conversion automatically carries the project context into the Sales Order.*
|
||||||
|
4. The Project dashboard now provides visibility into the commercial value and linked deliverables.
|
||||||
|
|
||||||
|
## 5. Determine Manufacturing & Supply Requirements (Demand Planning)
|
||||||
|
With the Sales Order firm and linked to a Project, determine what needs to be made or bought.
|
||||||
|
|
||||||
|
1. Navigate to the **Workbench** module, where the **Demand Planning** view is available for the Sales Order.
|
||||||
|
2. The system runs a **Multi-level BOM explosion** against the Sales Order lines, netting against current stock and open supply (existing POs/MOs).
|
||||||
|
3. The system will generate **Build/Buy recommendations** for any shortages.
|
||||||
|
|
||||||
|
## 6. Issue Purchase Orders (POs)
|
||||||
|
Fulfill the "Buy" recommendations.
|
||||||
|
|
||||||
|
1. From the Demand Planning recommendations, use the planner-assisted conversion to draft **Purchase Orders** for the required buyout items and raw materials.
|
||||||
|
2. The POs will automatically peg back to the source Sales Order and carry the Project context.
|
||||||
|
3. Preferred vendors from the inventory item master are selected by default.
|
||||||
|
4. Send the PO PDFs to vendors and manage receiving in the **Purchase Orders** and **Warehouse** modules.
|
||||||
|
|
||||||
|
## 7. Issue Manufacturing Orders (MOs/Work Orders)
|
||||||
|
Fulfill the "Build" recommendations.
|
||||||
|
|
||||||
|
1. Again, from the Demand Planning recommendations, convert the "Build" shortages into **Work Orders**.
|
||||||
|
2. The Work Orders define the execution plan (Operations/Stations) based on the item's routing templates in the **Manufacturing** module.
|
||||||
|
3. Provide the Work Order to the shop floor.
|
||||||
|
4. As operators log labor and issue materials against the Work Order, the costs roll up, and final completion posts the finished goods to inventory, making them available for shipment.
|
||||||
|
|
||||||
|
## 8. Assign Shipping
|
||||||
|
Once production is complete, deliver the goods.
|
||||||
|
|
||||||
|
1. Navigate to the **Shipments** module and create a **New Shipment**.
|
||||||
|
2. Link the Shipment directly to the **Sales Order**.
|
||||||
|
3. The system shows the ordered vs. picked vs. remaining quantities.
|
||||||
|
4. Execute **Shipment Picking**, which pulls stock from specific warehouse locations and posts the inventory issue transactions.
|
||||||
|
5. Update logistics details: Carrier, Service Level, Tracking Number, and Package Count.
|
||||||
|
6. Generate branded logistics PDFs directly from the Shipment: **Packing Slips, Shipping Labels, and Bills of Lading**.
|
||||||
|
7. The Shipment can also be seen from the linked Project, closing the loop on the delivery lifecycle.
|
||||||
Reference in New Issue
Block a user