init
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
client/node_modules
|
||||||
|
server/node_modules
|
||||||
|
shared/node_modules
|
||||||
|
client/dist
|
||||||
|
server/dist
|
||||||
|
data
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
DATABASE_URL="file:../../data/prisma/app.db"
|
||||||
|
DATA_DIR="./data"
|
||||||
|
CLIENT_ORIGIN="http://localhost:5173"
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.vite
|
||||||
|
.turbo
|
||||||
|
coverage
|
||||||
|
.tsbuildinfo
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
data
|
||||||
|
uploads
|
||||||
|
client/dist
|
||||||
|
server/dist
|
||||||
|
server/prisma/dev.db
|
||||||
|
server/prisma/dev.db-journal
|
||||||
|
client/src/**/*.js
|
||||||
|
client/src/**/*.d.ts
|
||||||
|
server/tests/**/*.js
|
||||||
|
client/tailwind.config.js
|
||||||
|
client/tailwind.config.d.ts
|
||||||
|
client/vite.config.js
|
||||||
|
client/vite.config.d.ts
|
||||||
|
*.log
|
||||||
168
AGENTS.md
Normal file
168
AGENTS.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This file defines project-specific guidance for future contributors and coding agents working in this repository. Follow it alongside the main project docs.
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
CODEXIUM is a modular Manufacturing Resource Planning platform intended to be a lighter, sleeker alternative to Odoo. The current repository contains the foundation release:
|
||||||
|
|
||||||
|
- React + Vite + Tailwind frontend
|
||||||
|
- Express + TypeScript backend
|
||||||
|
- Prisma + SQLite persistence
|
||||||
|
- local JWT auth and RBAC
|
||||||
|
- Company Settings and runtime branding
|
||||||
|
- filesystem-backed attachments
|
||||||
|
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
|
||||||
|
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
|
||||||
|
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
||||||
|
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
|
||||||
|
- inventory thumbnail image staging on create/edit and dedicated thumbnail display on item detail
|
||||||
|
- sales quotes, sales orders, approvals, revision history/comparison, and purchase orders
|
||||||
|
- purchase-order revision history and revision comparison across document and receipt changes
|
||||||
|
- purchase-order supporting documents and vendor-side purchasing visibility
|
||||||
|
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
|
||||||
|
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
|
||||||
|
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments
|
||||||
|
- planning gantt timelines backed by live project and manufacturing schedule data
|
||||||
|
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||||
|
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
||||||
|
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
|
||||||
|
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
|
||||||
|
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
|
||||||
|
- admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, and review filtering
|
||||||
|
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
|
||||||
|
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
|
||||||
|
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
||||||
|
- backup verification checklist and restore-drill runbook in the admin diagnostics workflow
|
||||||
|
- support-log viewing, filtering, retention cleanup, and support debugging helpers in the admin diagnostics workflow
|
||||||
|
- startup brand-theme hydration so Company Settings colors persist across refresh
|
||||||
|
- Puppeteer PDF foundation
|
||||||
|
- single-container Docker deployment
|
||||||
|
|
||||||
|
## Source of truth documents
|
||||||
|
|
||||||
|
Read these before major work:
|
||||||
|
|
||||||
|
- [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md)
|
||||||
|
- [README.md](D:/CODING/mrp-codex/README.md)
|
||||||
|
- [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md)
|
||||||
|
- [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md)
|
||||||
|
- [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md)
|
||||||
|
- [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md)
|
||||||
|
- [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md)
|
||||||
|
|
||||||
|
If implementation changes invalidate those docs, update them in the same change set. Keep `CHANGELOG.md` current for shipped features, behavior changes, and notable operational updates.
|
||||||
|
|
||||||
|
## Architecture rules
|
||||||
|
|
||||||
|
### Keep the app modular by domain
|
||||||
|
|
||||||
|
- Backend modules belong under `server/src/modules/<domain>`
|
||||||
|
- Frontend modules belong under `client/src/modules/<domain>`
|
||||||
|
- Shared contracts belong under `shared/src`
|
||||||
|
- Do not collapse unrelated business logic into the app shell or generic utility folders
|
||||||
|
|
||||||
|
### Backend boundaries
|
||||||
|
|
||||||
|
- Keep routers thin
|
||||||
|
- Put business logic in services
|
||||||
|
- Put persistence access behind Prisma usage in module services or focused helpers
|
||||||
|
- Keep auth, RBAC, storage, and Prisma setup in `server/src/lib`
|
||||||
|
- Keep environment and path configuration in `server/src/config`
|
||||||
|
|
||||||
|
### Frontend boundaries
|
||||||
|
|
||||||
|
- Shared UI primitives go in `client/src/components`
|
||||||
|
- Theme logic goes in `client/src/theme`
|
||||||
|
- Authentication state goes in `client/src/auth`
|
||||||
|
- Route-level business pages go in `client/src/modules`
|
||||||
|
- Do not mix PDF template concerns into normal UI pages
|
||||||
|
|
||||||
|
### Shared package constraints
|
||||||
|
|
||||||
|
- `shared` must stay framework-agnostic
|
||||||
|
- Use explicit `.js` relative exports/imports in `shared/src` because it ships as Node ESM
|
||||||
|
- Keep DTOs, permission keys, and cross-app types there
|
||||||
|
|
||||||
|
## Data and persistence rules
|
||||||
|
|
||||||
|
- SQLite database must live under `/app/data/prisma/app.db`
|
||||||
|
- Uploaded files must live under `/app/data/uploads`
|
||||||
|
- Never store file blobs in SQLite
|
||||||
|
- Store metadata and relative paths only
|
||||||
|
- Any persisted schema change must include a Prisma migration in `server/prisma/migrations`
|
||||||
|
|
||||||
|
## Prisma rules
|
||||||
|
|
||||||
|
- Run `npm run prisma:generate` after Prisma schema changes
|
||||||
|
- Use committed migrations as the source of truth
|
||||||
|
- Prefer Node 22 or Docker for Prisma migration execution
|
||||||
|
- Prisma client generation for Docker must continue to support the runtime binary target:
|
||||||
|
- `debian-openssl-3.0.x`
|
||||||
|
- Do not remove the current `binaryTargets` setting from `server/prisma/schema.prisma` unless the base image changes and the runtime target is updated intentionally
|
||||||
|
|
||||||
|
## Docker rules
|
||||||
|
|
||||||
|
- The Dockerfile is designed for command-line builds from the repo root
|
||||||
|
- Do not reintroduce Puppeteer browser downloads during image build
|
||||||
|
- The runtime image uses system Chromium at `/usr/bin/chromium`
|
||||||
|
- Container startup must continue to apply Prisma migrations before launching the app
|
||||||
|
- If Docker/runtime dependency handling changes, verify:
|
||||||
|
- Prisma binary is present
|
||||||
|
- Prisma client is generated in the runtime image
|
||||||
|
- shared ESM output resolves correctly in Node
|
||||||
|
|
||||||
|
## UI and product rules
|
||||||
|
|
||||||
|
- The application must remain brandable through centralized theme tokens and Company Settings
|
||||||
|
- Light and dark mode must remain first-class, not bolted on later
|
||||||
|
- New UI should respect the theme system and avoid hardcoded one-off colors where possible
|
||||||
|
- Keep the interface intentional and operational, not generic admin-template filler
|
||||||
|
- Non-filter operational lookups must use searchable pickers/autocomplete instead of long static dropdowns
|
||||||
|
- Keep the denser UI baseline on active screens unless a specific workflow needs more space
|
||||||
|
- Inventory items maintain both cost and price; sales entry should default from item price
|
||||||
|
- Purchase-order item lookup must only expose inventory items flagged as purchasable
|
||||||
|
- Customer-facing and logistics PDFs should continue to use the backend documents module and Puppeteer pipeline
|
||||||
|
- The landing experience should remain `Dashboard`, not `Overview`, and should evolve as a modular metric-first operational surface
|
||||||
|
- Projects are a first-class domain that anchors long-running program execution across CRM, sales, inventory, purchasing, shipping, and planning, and future work should continue extending that module rather than scattering project state elsewhere
|
||||||
|
- Manufacturing is now a first-class domain for work orders and inventory-backed execution, and future work should keep expanding it as a separate subsystem for routings, labor, and shop-floor control
|
||||||
|
- Planning should remain the scheduling/visibility layer over projects and manufacturing, not a replacement for either
|
||||||
|
- New top-level modules added to shell navigation should ship with a matching SVG icon, not text-only nav entries
|
||||||
|
|
||||||
|
## Feature expectations
|
||||||
|
|
||||||
|
Near-term priorities are:
|
||||||
|
|
||||||
|
1. Project milestones and project-side rollup visibility
|
||||||
|
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
|
|
||||||
|
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.
|
||||||
|
|
||||||
|
## Testing and verification
|
||||||
|
|
||||||
|
Before closing substantial work:
|
||||||
|
|
||||||
|
- run `npm run build`
|
||||||
|
- run `npm run test`
|
||||||
|
- if Docker-related code changed, rebuild the image if the environment allows it
|
||||||
|
- if Prisma schema changed, regenerate the client and confirm migrations are present
|
||||||
|
|
||||||
|
If you cannot run one of those checks, say so explicitly.
|
||||||
|
|
||||||
|
## Git and workflow expectations
|
||||||
|
|
||||||
|
- Keep commits focused and source-only; do not commit generated local build artifacts
|
||||||
|
- Update roadmap/docs and `CHANGELOG.md` when major work shifts priorities, architecture, or shipped functionality
|
||||||
|
- Do not remove or overwrite user changes without explicit instruction
|
||||||
|
- If a task reveals a persistent operational issue, document it rather than leaving it tribal knowledge
|
||||||
|
|
||||||
|
## Known pitfalls already encountered
|
||||||
|
|
||||||
|
- `npx prisma` from `/app` did not resolve correctly in the container; the entrypoint uses the server workspace binary directly
|
||||||
|
- Prisma client must be generated in the production dependency stage of the Docker build
|
||||||
|
- Prisma runtime on Debian bookworm requires `debian-openssl-3.0.x`
|
||||||
|
- `shared` package exports must use Node ESM-compatible `.js` specifiers
|
||||||
|
- Local Docker validation may fail if the Docker daemon is unavailable; distinguish daemon issues from image issues
|
||||||
|
|
||||||
151
CHANGELOG.md
Normal file
151
CHANGELOG.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
This file is the running release and change log for CODEXIUM. Keep it updated whenever shipped functionality, architecture expectations, deployment behavior, or operator-facing workflows materially change.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||||
|
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
|
||||||
|
- Dedicated inventory-item thumbnail display on the item detail page
|
||||||
|
- Revision comparison views for sales quotes, sales orders, and purchase orders with field- and line-level before/after diffs
|
||||||
|
- Purchase-order revision snapshots covering document edits, status changes, and receipt posting
|
||||||
|
- Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings
|
||||||
|
- Session filters and text search for admin-side access review across user, email, IP, user agent, and review reasons
|
||||||
|
- Support-log filtering by severity, source, search text, and retention window in admin diagnostics
|
||||||
|
- Support-log export and support-snapshot export now carry filter context, summary counts, available sources, and retention metadata
|
||||||
|
- Shared destructive-action confirmation dialog with impact and recovery guidance for high-risk operational actions
|
||||||
|
- Typed confirmation for sensitive admin actions such as account deactivation, current-session revocation, and terminal manufacturing/inventory postings
|
||||||
|
- Destructive-action confirmation and recovery coverage for sales approvals, quote conversion, purchase receiving, purchase status changes, and shipment status changes
|
||||||
|
- Destructive-action confirmation coverage for project customer/document unlinking and embedded form-row removals in sales, purchasing, inventory, and warehouse editors
|
||||||
|
- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins
|
||||||
|
- Admin-side session revocation controls plus server-side logout that invalidates the current JWT-backed session
|
||||||
|
- Shared shortage and readiness rollups across dashboard, planning, project detail, purchasing detail, and manufacturing detail
|
||||||
|
- Prefilled work-order draft launch for build recommendations and prefilled purchase-order draft launch for buy recommendations from sales-order demand planning
|
||||||
|
- Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children
|
||||||
|
- Netting of sales-order demand against available stock, active reservations, open work orders, and open purchase orders
|
||||||
|
- Build and buy recommendations surfaced directly on sales-order detail pages
|
||||||
|
- Pegged work-order and purchase-order supply tracking back to sales demand so reopened planning views do not overstate remaining recommendations
|
||||||
|
- Preferred-vendor sourcing on inventory items, used to preseed buy-side planning workflows
|
||||||
|
- Demand-planning recommendations now reduce against existing linked WO/PO supply and support safer partial conversion behavior
|
||||||
|
- Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics
|
||||||
|
- Exportable support bundles that now include backup guidance and recent support logs for support handoff
|
||||||
|
- Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups
|
||||||
|
- Backup verification checklist and restore-drill runbook surfaced in admin diagnostics
|
||||||
|
- Backup/restore guidance surfaced in admin diagnostics with exportable support snapshot JSON for support handoff
|
||||||
|
- CRM customer/vendor changes and shipping mutations now feed the shared audit trail
|
||||||
|
- Startup validation now runs during server boot and surfaces readiness checks in admin diagnostics
|
||||||
|
- Admin user-management screen with account creation, activation control, role assignment, and role-permission editing
|
||||||
|
- Route-level lazy loading and vendor chunking across the client so major operational modules no longer ship in the initial bundle
|
||||||
|
- Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
|
||||||
|
- Admin diagnostics page with runtime footprint, storage-path visibility, key record counts, and recent audit activity
|
||||||
|
- Inventory transfers with paired physical stock movement posting between warehouses and locations
|
||||||
|
- Manual inventory reservations plus automatic work-order-driven component reservations
|
||||||
|
- Reserved and available stock visibility on inventory item detail and stock-by-location views
|
||||||
|
- Manufacturing stations with queue-day definitions and item-level station/time operation templates
|
||||||
|
- Automatic work-order operation plans copied from buildable item routing into planning/gantt
|
||||||
|
- Live planning gantt timelines backed by active projects and open manufacturing work orders
|
||||||
|
- Planning summary metrics and exception cards for overdue or at-risk project/manufacturing schedule items
|
||||||
|
- Sales approval actions with approved-by/approved-at stamps on quotes and sales orders
|
||||||
|
- Automatic sales-document revision history with authored reasons and per-revision snapshots
|
||||||
|
- Projects domain foundation with customer, owner, due date, priority, notes, and attachment support
|
||||||
|
- Project linkage to sales quotes, sales orders, and shipments for cross-module delivery tracking
|
||||||
|
- Project list/detail/create/edit workflows and app-shell navigation entry
|
||||||
|
- Dashboard project widgets for active, at-risk, and overdue program visibility
|
||||||
|
- Manufacturing foundation with work orders, optional project linkage, work-order attachments, and app-shell navigation entry
|
||||||
|
- BOM-based manufacturing requirement visibility plus material issue and completion posting through inventory transactions
|
||||||
|
- Dashboard manufacturing widgets for released, active, and overdue work visibility
|
||||||
|
- Purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and procurement backup files
|
||||||
|
- Vendor-detail purchasing visibility with recent purchase-order activity and PO launch shortcuts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
|
||||||
|
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
|
||||||
|
- Admin user edits now normalize blank passwords to null and surface save failures instead of appearing unresponsive
|
||||||
|
- Fresh bootstrap now creates only minimal system records; operational domains no longer start with seeded demo customers, vendors, inventory, BOMs, or warehouses
|
||||||
|
- Sales and purchasing detail pages now expose revision comparison directly alongside chronological revision history
|
||||||
|
- `ROADMAP.md` now tracks remaining work only, and shipped phase history now lives in `SHIPPED.md`
|
||||||
|
- Support logs now prune retained entries by age instead of only trimming by count, and admin diagnostics now reviews filtered support-log summaries instead of an unbounded flat dump
|
||||||
|
- Admin diagnostics now summarizes sessions that need review, and startup now prunes old expired or revoked auth-session records
|
||||||
|
- Admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse, and attachment workflows now use explicit destructive-action confirmation and recovery messaging instead of immediate irreversible clicks
|
||||||
|
- Admin operations now combine user management with live session visibility so operators can inspect and revoke sign-ins without changing user records
|
||||||
|
- JWT authentication now validates against persisted session records and inactive users lose access immediately instead of waiting for token expiry
|
||||||
|
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
|
||||||
|
- The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects
|
||||||
|
- The dashboard now treats Planning as a live first-class module with direct gantt access from the landing page
|
||||||
|
- Inventory control now distinguishes on-hand, reserved, and available stock instead of treating all positive stock as fully free
|
||||||
|
- Manufacturing and inventory now share a routing-driven workflow where assemblies/manufactured parts define station/time templates and work orders inherit them automatically
|
||||||
|
- Sales quote and sales-order detail pages now surface approval state and revision history directly in the operational workflow
|
||||||
|
- Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns
|
||||||
|
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
|
||||||
|
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
|
||||||
|
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
|
||||||
|
- The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx`
|
||||||
|
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
|
||||||
|
- Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity
|
||||||
|
- Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging
|
||||||
|
- Roadmap and project docs now treat demand planning and supply generation as its own phase ahead of the deferred admin QOL work
|
||||||
|
- Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice
|
||||||
|
- Manufacturing work-order material requirements now include live available/shortage visibility instead of only required-versus-issued math
|
||||||
|
- Roadmap and project docs now move back to admin session visibility and destructive-action safety after the demand-planning rollout
|
||||||
|
|
||||||
|
## 2026-03-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Dashboard foundation followed by live-data dashboard cards and operational summary widgets
|
||||||
|
- Purchase-order domain foundation with searchable vendor and SKU workflows
|
||||||
|
- Purchase receiving foundation tied to purchase orders, including warehouse/location receipt posting, receipt history, and per-line received and remaining quantity tracking
|
||||||
|
- Branded quote, sales-order, and purchase-order PDFs through the shared backend Puppeteer document pipeline
|
||||||
|
- Navigation icon support for top-level modules
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Purchasing detail workflows now support operational receiving directly from the purchase-order record
|
||||||
|
- Sales and purchasing detail pages now expose one-click PDF rendering actions
|
||||||
|
- Dashboard direction and project instructions were tightened through new documentation guidance
|
||||||
|
- Roadmap and project docs now treat shipping/logistics documents as the next active priority after receiving and commercial PDFs
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Purchase-order follow-up fixes after the initial purchasing rollout
|
||||||
|
- PDF rendering and document-pipeline fixes after the first document commits
|
||||||
|
- General refinement pass across active workflows after the initial March 15 feature wave
|
||||||
|
|
||||||
|
## 2026-03-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial MRP foundation scaffold
|
||||||
|
- Docker build and runtime foundation, plus Unraid deployment guidance
|
||||||
|
- Prisma setup, migration fixes, npm/workspace fixes, and supporting JS/runtime cleanup
|
||||||
|
- Auth/RBAC foundation, dark mode support, and early UI cleanup
|
||||||
|
- CRM foundation through customer/vendor records, hierarchy, contacts, lifecycle metadata, images, and final CRM polish
|
||||||
|
- Inventory foundation through item master, BOMs, warehouses, stock locations, searchable SKU flows, and inventory detail refinements
|
||||||
|
- Dense workspace and compact UI passes across the active product surface
|
||||||
|
- Sales foundation with searchable document entry, conversion actions, default cost/price support, and totals/commercial logic
|
||||||
|
- Shipping foundation with sales-order linkage and packing-slip PDFs
|
||||||
|
- Company-profile and document PDF foundation with subsequent documentation updates
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Inventory default-price support flowing into sales documents
|
||||||
|
- Purchase-order line restrictions to purchasable inventory items only
|
||||||
|
- Search and focus behavior received follow-up fixes during CRM/inventory/sales rollout
|
||||||
|
- Documentation was expanded repeatedly as the foundation scope grew
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prisma reliability and workspace setup issues discovered during the first foundation build-out
|
||||||
|
- SKU picker and autocomplete issues during inventory rollout
|
||||||
|
- Focus and autosuggest regressions during dense operational form work
|
||||||
|
|
||||||
|
### Known follow-up areas
|
||||||
|
|
||||||
|
- Shipping labels, bills of lading, and logistics attachments
|
||||||
|
- Vendor invoice/supporting-document attachments
|
||||||
|
- Sales approvals and document revision history
|
||||||
|
- Projects, manufacturing execution, and planning depth
|
||||||
|
|
||||||
95
Dockerfile
Normal file
95
Dockerfile
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
ARG NODE_VERSION=22
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY client/package.json client/package.json
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
COPY shared/package.json shared/package.json
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run prisma:generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS prod-deps
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY client/package.json client/package.json
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
COPY shared/package.json shared/package.json
|
||||||
|
COPY server/prisma server/prisma
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund
|
||||||
|
RUN npm run prisma:generate -w server
|
||||||
|
RUN test -x /app/server/node_modules/.bin/prisma
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-bookworm-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
ENV DATABASE_URL=file:../../data/prisma/app.db
|
||||||
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
chromium \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libc6 \
|
||||||
|
libcairo2 \
|
||||||
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libexpat1 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libgbm1 \
|
||||||
|
libgcc1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libstdc++6 \
|
||||||
|
libx11-6 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxcb1 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxcursor1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxi6 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxrender1 \
|
||||||
|
libxss1 \
|
||||||
|
libxtst6 \
|
||||||
|
xdg-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||||
|
COPY --from=prod-deps /app/server/node_modules /app/server/node_modules
|
||||||
|
COPY --from=build /app/client/dist /app/client/dist
|
||||||
|
COPY --from=build /app/server/dist /app/server/dist
|
||||||
|
COPY --from=build /app/shared/dist /app/shared/dist
|
||||||
|
COPY --from=build /app/server/package.json /app/server/package.json
|
||||||
|
COPY --from=build /app/shared/package.json /app/shared/package.json
|
||||||
|
COPY --from=build /app/server/prisma /app/server/prisma
|
||||||
|
COPY --from=build /app/docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY README.md INSTRUCTIONS.md STRUCTURE.md ROADMAP.md UNRAID.md ./
|
||||||
|
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
82
INSTRUCTIONS.md
Normal file
82
INSTRUCTIONS.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Development Instructions
|
||||||
|
|
||||||
|
## Documentation maintenance
|
||||||
|
|
||||||
|
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated whenever shipped functionality, architecture expectations, deployment behavior, or user-facing workflows materially change.
|
||||||
|
- If a change invalidates [README.md](D:/CODING/mrp-codex/README.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md), or [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), update those files in the same change set.
|
||||||
|
|
||||||
|
## Current milestone
|
||||||
|
|
||||||
|
This repository implements the platform foundation milestone:
|
||||||
|
|
||||||
|
- workspace scaffolding
|
||||||
|
- local auth and RBAC
|
||||||
|
- company settings and branding
|
||||||
|
- file attachment storage
|
||||||
|
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
|
||||||
|
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
|
||||||
|
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
||||||
|
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
|
||||||
|
- inventory thumbnail image staging on create/edit and dedicated thumbnail display on item detail
|
||||||
|
- sales quotes and sales orders with quick actions and quote conversion
|
||||||
|
- sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
|
||||||
|
- purchase orders with quick actions and searchable vendor/SKU entry
|
||||||
|
- purchase orders restricted to inventory items flagged as purchasable
|
||||||
|
- purchase receiving foundation with inventory posting and receipt history
|
||||||
|
- purchase-order revision history and revision comparison across document and receipt changes
|
||||||
|
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline
|
||||||
|
- purchase-order supporting documents and vendor-side purchasing visibility
|
||||||
|
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
|
||||||
|
- projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility
|
||||||
|
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility
|
||||||
|
- planning gantt timelines backed by live project and manufacturing schedule data
|
||||||
|
- sales-order demand planning with multi-level BOM explosion, net stock/open-supply coverage, and build/buy recommendations
|
||||||
|
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
||||||
|
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
|
||||||
|
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
|
||||||
|
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
|
||||||
|
- admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, and review filtering
|
||||||
|
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
|
||||||
|
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
||||||
|
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
|
||||||
|
- backup verification checklist and restore-drill runbook in diagnostics
|
||||||
|
- support-log viewing, filtering, retention cleanup, and support debugging helpers in diagnostics
|
||||||
|
- startup brand-theme hydration so Company Settings colors persist correctly across refresh
|
||||||
|
- Dockerized single-container deployment
|
||||||
|
- Puppeteer PDF pipeline foundation
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Update the roadmap before starting large features.
|
||||||
|
2. Keep backend and frontend modules grouped by domain.
|
||||||
|
3. Add Prisma models and migrations for all persisted schema changes.
|
||||||
|
4. Keep uploaded files on disk under `/app/data/uploads`; never store blobs in SQLite.
|
||||||
|
5. Reuse shared DTOs and permission keys from the `shared` package.
|
||||||
|
6. Any non-filter UI that looks up records or items must use a searchable picker/autocomplete, not a long static dropdown.
|
||||||
|
7. Inventory items must carry both `defaultCost` and `defaultPrice`; sales documents should default line pricing from the selected item `defaultPrice`.
|
||||||
|
8. Maintain the denser UI baseline on active screens; avoid reintroducing oversized `px-4 py-3` style controls, tall action bars, or overly loose card spacing without a specific reason.
|
||||||
|
9. Treat the landing page as `Dashboard`: a metric-oriented, modular command surface that should accumulate reusable operational panels over time.
|
||||||
|
10. Purchase-order item selection must be restricted to inventory items where `isPurchasable = true`.
|
||||||
|
11. Treat `Projects` as a first-class cross-module domain tying together CRM, sales, inventory, purchasing, shipping, and planning; do not bury it as a one-off manufacturing subfeature.
|
||||||
|
12. Keep `Projects`, `Manufacturing`, and `Planning` distinct: projects are long-running program records, manufacturing is execution, and planning is scheduling/visibility.
|
||||||
|
13. New top-level modules added to the app shell should include a matching SVG icon in navigation so the module list remains visually scannable.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- Run `npm run prisma:generate` after schema changes.
|
||||||
|
- Run `npm run prisma:migrate` during development to create versioned migrations.
|
||||||
|
- Use `npm run prisma:deploy` in production environments.
|
||||||
|
- Prefer Node 22 locally when running Prisma migration commands to match the Docker runtime.
|
||||||
|
- Branding defaults live in the frontend theme token layer and are overridden by the persisted company profile.
|
||||||
|
- Back up the whole `/app/data` volume to capture both the database and attachments.
|
||||||
|
- Treat searchable lookup as a standing UX requirement for inventory, BOM, sales, purchasing, manufacturing, customer, vendor, and other operational record-picking flows. Filter-only controls can still use dropdowns.
|
||||||
|
- Extend the existing Puppeteer document pipeline when adding customer-facing or logistics PDFs instead of creating a parallel export mechanism.
|
||||||
|
- Add future dashboard features as modular metric/action panels instead of one-off hero sections or static marketing-style content.
|
||||||
|
- When implementing projects, model the relationships explicitly so project records can anchor execution across customer, order, material, schedule, and shipment workflows.
|
||||||
|
- When implementing manufacturing, keep work orders, routings, labor, and shop-floor execution in their own domain rather than collapsing them into projects.
|
||||||
|
|
||||||
|
## Next roadmap candidates
|
||||||
|
|
||||||
|
- project milestones and project-side rollup visibility
|
||||||
|
- manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
|
|
||||||
401
README.md
Normal file
401
README.md
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
# CODEXIUM
|
||||||
|
|
||||||
|
Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment.
|
||||||
|
|
||||||
|
## Documentation Maintenance
|
||||||
|
|
||||||
|
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated for shipped features, workflow changes, and notable operational updates.
|
||||||
|
- Keep [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) aligned when changes affect their scope.
|
||||||
|
|
||||||
|
Current foundation scope includes:
|
||||||
|
|
||||||
|
- authentication and RBAC
|
||||||
|
- company branding and theme settings, including startup brand-theme hydration across refresh
|
||||||
|
- CRM customers and vendors with create/edit/detail workflows
|
||||||
|
- CRM search, filtering, status tagging, and reseller hierarchy
|
||||||
|
- CRM contact history, account contacts, and shared attachments
|
||||||
|
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
|
||||||
|
- inventory transfers, reservations, and available-stock visibility
|
||||||
|
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
|
||||||
|
- staged thumbnail image attachment on inventory item create/edit workflows, with detail-page thumbnail display
|
||||||
|
- sales quotes and sales orders with searchable customer and SKU entry
|
||||||
|
- sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
|
||||||
|
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
|
||||||
|
- purchase-order revision history and revision comparison across commercial and receipt changes
|
||||||
|
- purchase receiving with warehouse/location posting and receipt history against purchase orders
|
||||||
|
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
||||||
|
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||||
|
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
|
||||||
|
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
|
||||||
|
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
|
||||||
|
- planning gantt timelines with live project and manufacturing schedule data
|
||||||
|
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||||
|
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
||||||
|
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
|
||||||
|
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
|
||||||
|
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
|
||||||
|
- admin user management with account creation, activation, role assignment, role-permission editing, session visibility/revocation, review filtering, and unusual-access cues
|
||||||
|
- safer destructive-action confirmations and recovery messaging across admin, sales, purchasing, shipping, inventory, manufacturing, projects, warehouse/form editors, and attachment workflows
|
||||||
|
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
|
||||||
|
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
||||||
|
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics
|
||||||
|
- support-log viewing, filtering, retention cleanup, and richer support-debug export helpers in admin diagnostics
|
||||||
|
- route-level code-splitting and vendor chunking for lighter initial client loads
|
||||||
|
- file storage and PDF rendering
|
||||||
|
|
||||||
|
## Product Map
|
||||||
|
|
||||||
|
Shipped phase history now lives in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md). [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) now tracks remaining work only.
|
||||||
|
|
||||||
|
Current completed foundation areas:
|
||||||
|
|
||||||
|
- dashboard foundation
|
||||||
|
- CRM foundation
|
||||||
|
- inventory foundation
|
||||||
|
- sales and purchasing foundation
|
||||||
|
- shipping foundation
|
||||||
|
- projects foundation
|
||||||
|
- manufacturing foundation
|
||||||
|
- planning foundation
|
||||||
|
- audit and diagnostics foundation
|
||||||
|
- user and role administration foundation
|
||||||
|
- branding, attachments, auth/RBAC, and PDF infrastructure
|
||||||
|
|
||||||
|
Near-term priorities:
|
||||||
|
|
||||||
|
1. Project milestones and project-side rollup visibility
|
||||||
|
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
|
|
||||||
|
Revisit / deferred items:
|
||||||
|
|
||||||
|
- local Windows Prisma migration reliability
|
||||||
|
- project milestones and project-side rollup visibility
|
||||||
|
|
||||||
|
Dashboard direction:
|
||||||
|
|
||||||
|
- the landing page is now `Dashboard`, not `Overview`
|
||||||
|
- it should remain a metric-oriented operational surface rather than a generic welcome page
|
||||||
|
- new modules should add reusable dashboard cards/panels instead of replacing the whole layout
|
||||||
|
- future additions should emphasize relevant metrics, next actions, alerts, and workflow shortcuts
|
||||||
|
- richer recent-activity widgets and exception queues are a planned QOL follow-up, not a separate landing-page redesign
|
||||||
|
- projects now feed dashboard widgets for active programs, overdue work, and risk
|
||||||
|
- manufacturing now feeds dashboard widgets for released work, overdue orders, and execution load
|
||||||
|
- planning now feeds live gantt scheduling from project and manufacturing records
|
||||||
|
- future project widgets should deepen milestones, shortages, and shipment readiness
|
||||||
|
|
||||||
|
Navigation direction:
|
||||||
|
|
||||||
|
- module navigation now uses inline SVG icons alongside labels
|
||||||
|
- new modules should add a clear, domain-appropriate SVG icon when they are added to the shell
|
||||||
|
- icons should stay lightweight, theme-aware, and dependency-free unless there is a strong reason to introduce a shared icon package
|
||||||
|
|
||||||
|
## Projects Direction
|
||||||
|
|
||||||
|
Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, notes, commercial document links, shipment links, attachments, and dashboard visibility.
|
||||||
|
|
||||||
|
Current interactions:
|
||||||
|
|
||||||
|
- CRM: each project should link to a customer account and relevant contacts
|
||||||
|
- Sales: quotes and sales orders can already attach to projects
|
||||||
|
- Shipping: shipments tied to project deliverables are visible from the project record
|
||||||
|
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets
|
||||||
|
|
||||||
|
Next expansion areas:
|
||||||
|
|
||||||
|
- Inventory: projects should reference item/BOM scope and later expose shortages or allocations
|
||||||
|
- Purchasing: project material demand should be visible to purchasing and receiving workflows
|
||||||
|
- Manufacturing: work orders should link back to projects without turning projects into the manufacturing module
|
||||||
|
- Planning: project milestones and execution dates should feed gantt scheduling and dependency views
|
||||||
|
|
||||||
|
## Manufacturing Direction
|
||||||
|
|
||||||
|
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, automatic work-order operation plans, material issue posting, completion posting, work-order attachments, and dashboard visibility.
|
||||||
|
|
||||||
|
Current interactions:
|
||||||
|
|
||||||
|
- Projects: manufacturing orders may belong to a project, but projects remain the higher-level long-running record
|
||||||
|
- Inventory: manufacturing consumes components and produces stock through real issue/receipt transactions
|
||||||
|
- Dashboard: manufacturing now contributes released/open/overdue load widgets
|
||||||
|
|
||||||
|
Next expansion areas:
|
||||||
|
|
||||||
|
- Purchasing: shortages and buyout demand should surface from manufacturing execution
|
||||||
|
- Shipping: completed manufacturing should feed shipment readiness
|
||||||
|
- Planning: manufacturing orders, routings, and work centers should drive capacity and schedule views
|
||||||
|
|
||||||
|
## Planning Direction
|
||||||
|
|
||||||
|
Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a gantt surface backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, and exception cards for overdue or at-risk schedule items.
|
||||||
|
|
||||||
|
Current interactions:
|
||||||
|
|
||||||
|
- Projects: project timelines and due dates anchor the top-level planning rows
|
||||||
|
- Manufacturing: open work orders feed task rows, sequencing links, and execution progress
|
||||||
|
- Dashboard: planning now appears as a first-class module with schedule visibility links
|
||||||
|
|
||||||
|
Next expansion areas:
|
||||||
|
|
||||||
|
- Purchasing: shortages, late receipts, and vendor risk should surface directly in planning
|
||||||
|
- Manufacturing: routings, work centers, and capacity should deepen the schedule model
|
||||||
|
- Projects: richer milestones and dependency editing should extend the project-level timeline
|
||||||
|
|
||||||
|
## Workspace
|
||||||
|
|
||||||
|
- `client`: React, Vite, Tailwind frontend
|
||||||
|
- `server`: Express API, Prisma, auth/RBAC, file storage, PDF rendering
|
||||||
|
- `shared`: shared TypeScript contracts and constants
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
1. Use Node.js 22 for local development if you want Prisma migration commands to behave the same way as Docker.
|
||||||
|
2. Install dependencies with `npm.cmd install`.
|
||||||
|
3. Copy [`.env.example`](D:\CODING\mrp-codex\.env.example) to `.env` and adjust values if needed.
|
||||||
|
4. Generate Prisma client with `npm run prisma:generate`.
|
||||||
|
5. Apply committed migrations with `npm run prisma:deploy`.
|
||||||
|
6. Start the workspace with `npm run dev`.
|
||||||
|
|
||||||
|
The frontend runs through Vite in development and is served statically by the API in production.
|
||||||
|
|
||||||
|
Seeded admin credentials for first login:
|
||||||
|
|
||||||
|
- email: `admin@mrp.local`
|
||||||
|
- password: `ChangeMe123!`
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t mrp-codex .
|
||||||
|
docker run -p 3000:3000 -v mrp_data:/app/data mrp-codex
|
||||||
|
```
|
||||||
|
|
||||||
|
Command-line build notes:
|
||||||
|
|
||||||
|
- The Dockerfile is intended to be built directly from the repo root with `docker build`
|
||||||
|
- `puppeteer` browser download is disabled during image build because the runtime image installs system Chromium
|
||||||
|
- You can override the Node base image version if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg NODE_VERSION=22 -t mrp-codex .
|
||||||
|
```
|
||||||
|
|
||||||
|
The container startup script runs the server workspace Prisma binary directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM and inventory migrations on Windows.
|
||||||
|
|
||||||
|
## Persistence And Backup
|
||||||
|
|
||||||
|
- SQLite database path: `/app/data/prisma/app.db`
|
||||||
|
- Uploaded files: `/app/data/uploads`
|
||||||
|
- Backup the entire mounted `/app/data` volume to preserve both records and attachments.
|
||||||
|
|
||||||
|
## CRM
|
||||||
|
|
||||||
|
The current CRM foundation supports:
|
||||||
|
|
||||||
|
- customer and vendor list, detail, create, and edit flows
|
||||||
|
- search by text plus status and state/province filters
|
||||||
|
- customer reseller flag, reseller discount, and parent-child hierarchy
|
||||||
|
- contact-history timeline entries for notes, calls, emails, and meetings
|
||||||
|
- multiple account contacts with role and primary-contact tracking
|
||||||
|
- shared file attachments on customer and vendor records
|
||||||
|
- commercial terms fields including payment terms, currency, tax exempt, and credit hold
|
||||||
|
|
||||||
|
QOL direction:
|
||||||
|
|
||||||
|
- saved filters and quick views
|
||||||
|
- cleaner hierarchy navigation for reseller/customer trees
|
||||||
|
- richer downstream rollups once purchasing and shipping grow further
|
||||||
|
|
||||||
|
Recent CRM features depend on the committed Prisma migrations being applied. If you update the code and do not run migrations, the UI may render fields that are not yet present in the database.
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
The current inventory foundation supports:
|
||||||
|
|
||||||
|
- protected item master list, detail, create, and edit flows
|
||||||
|
- SKU, description, type, status, unit-of-measure, sellable/purchasable, default cost, default price, and notes fields
|
||||||
|
- BOM header and BOM line editing directly on the item form
|
||||||
|
- searchable component lookup for BOM lines, designed for large item catalogs
|
||||||
|
- SKU-first searchable component lookup for BOM lines, with SKU shown in the picker and description kept separate in the selected row
|
||||||
|
- BOM detail display with component SKU, name, quantity, unit, notes, and position
|
||||||
|
- protected warehouse list, detail, create, and edit flows
|
||||||
|
- nested stock-location management inside each warehouse record
|
||||||
|
- inventory transaction posting for receipts, issues, and adjustments
|
||||||
|
- inventory transfers with paired source/destination movement posting
|
||||||
|
- manual reservations plus automatic work-order component reservations
|
||||||
|
- item on-hand quantity, stock-by-location balances, and recent stock history
|
||||||
|
- reserved and available quantity visibility by location
|
||||||
|
- item-level file attachments for drawings and support documents
|
||||||
|
- fresh bootstrap starts inventory and warehouse data empty so first-run environments do not include demo operational records
|
||||||
|
|
||||||
|
QOL direction:
|
||||||
|
|
||||||
|
- clearer warehouse dashboards and shortage views
|
||||||
|
- BOM revisions and where-used visibility
|
||||||
|
|
||||||
|
This module introduces `inventory.read` and `inventory.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||||
|
|
||||||
|
`defaultPrice` now flows from inventory items into quote and sales-order line entry when a SKU is selected. Users can still override the line price after selection.
|
||||||
|
|
||||||
|
## Sales
|
||||||
|
|
||||||
|
The current sales foundation supports:
|
||||||
|
|
||||||
|
- quote and sales-order list, detail, create, and edit flows
|
||||||
|
- searchable customer lookup instead of static customer dropdowns
|
||||||
|
- SKU-searchable line entry for quote and order lines
|
||||||
|
- document-level discount, tax, freight, subtotal, and total calculations
|
||||||
|
- reseller discount defaulting from customer records into sales documents
|
||||||
|
- status quick actions directly from quote and order detail pages
|
||||||
|
- quote conversion into a sales order
|
||||||
|
- line-level unit prices populated from the selected inventory item default price
|
||||||
|
- branded quote and sales-order PDFs through the shared document pipeline
|
||||||
|
- approval stamps and revision history directly on quote and sales-order detail pages
|
||||||
|
- revision-reason capture when editing customer-facing sales documents
|
||||||
|
|
||||||
|
QOL direction:
|
||||||
|
|
||||||
|
- line duplication and faster keyboard-heavy line editing
|
||||||
|
- revision comparison view and restore-style workflows
|
||||||
|
- richer PDF output for quotes and sales orders
|
||||||
|
|
||||||
|
This module introduces `sales.read` and `sales.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||||
|
|
||||||
|
## Purchasing
|
||||||
|
|
||||||
|
The current purchasing foundation supports:
|
||||||
|
|
||||||
|
- purchase-order list, detail, create, and edit flows
|
||||||
|
- searchable vendor lookup instead of a static vendor dropdown
|
||||||
|
- SKU-searchable line entry for purchase-order lines
|
||||||
|
- purchase-order item lookup restricted to inventory items flagged as purchasable
|
||||||
|
- receiving workflow tied to purchase orders, with receipt history and inventory posting
|
||||||
|
- document-level tax, freight, subtotal, and total calculations
|
||||||
|
- quick status actions directly from purchase-order detail pages
|
||||||
|
- vendor payment terms and currency surfaced on purchase-order forms and details
|
||||||
|
- branded purchase-order PDFs through the shared document pipeline
|
||||||
|
|
||||||
|
QOL direction:
|
||||||
|
|
||||||
|
- richer dashboard widgets for vendor queues and inbound material exceptions
|
||||||
|
- vendor-side exception tracking around acknowledgements, invoice matching, and receipt discrepancies
|
||||||
|
|
||||||
|
This module introduces `purchasing.read` and `purchasing.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||||
|
|
||||||
|
## Shipping
|
||||||
|
|
||||||
|
The current shipping foundation supports:
|
||||||
|
|
||||||
|
- shipment list, detail, create, and edit flows
|
||||||
|
- searchable sales-order lookup instead of a static order dropdown
|
||||||
|
- shipment records linked directly to sales orders
|
||||||
|
- carrier, service level, tracking number, package count, notes, and ship date fields
|
||||||
|
- shipment quick status actions from the shipment detail page
|
||||||
|
- related-shipment visibility from the sales-order detail page
|
||||||
|
- branded packing-slip PDF rendering from shipment detail pages
|
||||||
|
- branded shipping-label and bill-of-lading PDF rendering from shipment detail pages
|
||||||
|
- logistics attachments directly on shipment records
|
||||||
|
|
||||||
|
QOL direction:
|
||||||
|
|
||||||
|
- reprint/history actions for generated logistics PDFs
|
||||||
|
- partial-shipment and split-shipment UX
|
||||||
|
|
||||||
|
This module introduces `shipping.read` and `shipping.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
|
||||||
|
|
||||||
|
Moving forward, any UI that requires searching for records or items should use a searchable picker/autocomplete rather than a static dropdown. Filter controls can remain dropdowns, but non-filter lookup fields such as SKU pickers, customer selectors, vendor selectors, and similar operational search inputs should not be implemented as long static selects.
|
||||||
|
|
||||||
|
## UI Density
|
||||||
|
|
||||||
|
The active client screens have been normalized toward a denser workspace layout:
|
||||||
|
|
||||||
|
- form controls and action bars use tighter padding
|
||||||
|
- CRM, inventory, warehouse, settings, dashboard, and login screens use reduced card spacing
|
||||||
|
- headings, metric cards, empty states, and long-text blocks were tightened for better data density
|
||||||
|
|
||||||
|
This denser layout is now the baseline for future screens. New pages should avoid reverting to oversized card padding, tall action bars, or long static dropdowns for operational datasets.
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand.
|
||||||
|
|
||||||
|
Logo uploads are stored through the authenticated file pipeline and are rendered back into the settings UI through an authenticated blob fetch, so image preview works after save and refresh.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
- Create a local migration: `npm run prisma:migrate`
|
||||||
|
- Apply committed migrations in production: `npm run prisma:deploy`
|
||||||
|
- If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth.
|
||||||
|
|
||||||
|
As of March 15, 2026, the latest committed domain migrations include:
|
||||||
|
|
||||||
|
- CRM status and list filters
|
||||||
|
- CRM contact-history timeline
|
||||||
|
- reseller hierarchy and reseller discount support
|
||||||
|
- CRM commercial terms and account contacts
|
||||||
|
- CRM lifecycle stages and operational metadata
|
||||||
|
- inventory item master and BOM foundation
|
||||||
|
- warehouse and stock-location foundation
|
||||||
|
- inventory transactions and on-hand tracking
|
||||||
|
- sales quote and sales-order foundation
|
||||||
|
- purchase-order foundation
|
||||||
|
- purchase receiving foundation
|
||||||
|
- branded sales and purchasing PDF templates
|
||||||
|
- sales approvals and document revision history
|
||||||
|
- inventory default price support
|
||||||
|
- sales totals and commercial fields
|
||||||
|
- shipping foundation
|
||||||
|
- projects foundation
|
||||||
|
- manufacturing foundation
|
||||||
|
- manufacturing stations and operation templates
|
||||||
|
- inventory transfers and reservations
|
||||||
|
- audit trail and diagnostics foundation
|
||||||
|
- auth-session visibility and revocation
|
||||||
|
- session review filters, unusual-access cues, and startup pruning of stale expired/revoked session records
|
||||||
|
- supply pegging and preferred-vendor sourcing
|
||||||
|
|
||||||
|
Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment.
|
||||||
|
|
||||||
|
## Audit And Diagnostics
|
||||||
|
|
||||||
|
The current admin operations slice supports:
|
||||||
|
|
||||||
|
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
|
||||||
|
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
|
||||||
|
- a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations
|
||||||
|
- prefilled work-order and purchase-order draft launch paths from sales-order demand-planning recommendations
|
||||||
|
- shared shortage/readiness rollups across planning, project, purchasing, dashboard, and manufacturing views
|
||||||
|
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, role-permission administration, and session visibility/revocation
|
||||||
|
- session review filters and flagged cues for stale activity, multi-session overlap, and multi-IP access patterns
|
||||||
|
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
|
||||||
|
- startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
|
||||||
|
- startup now prunes stale expired or revoked auth-session records before serving requests
|
||||||
|
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff
|
||||||
|
- support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review
|
||||||
|
- support logs now support admin-side filtering by severity, source, search text, and retention window, and exports include summary metadata
|
||||||
|
- backup verification items and restore-drill expected outcomes now live in the admin runbook surface
|
||||||
|
- operator-facing review of recent high-impact changes without direct database access
|
||||||
|
|
||||||
|
Current follow-up direction:
|
||||||
|
|
||||||
|
- revision comparison UX for changed sales and purchasing documents
|
||||||
|
- project milestones and project-side rollup visibility
|
||||||
|
|
||||||
|
## UI Notes
|
||||||
|
|
||||||
|
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
|
||||||
|
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
|
||||||
|
- The active module screens now follow a tighter density baseline for forms, tables, and detail cards.
|
||||||
|
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data.
|
||||||
|
- The client now ships with route-level lazy loading and vendor chunking, so future frontend work should preserve that split instead of re-centralizing module imports in `main.tsx`.
|
||||||
|
|
||||||
|
## PDF Generation
|
||||||
|
|
||||||
|
Puppeteer is used by the backend to render HTML templates into professional PDFs. The current PDF surface includes the branded company-profile preview, sales quotes, sales orders, purchase orders, shipment packing slips, shipping labels, and bills of lading. The Docker image includes Chromium runtime dependencies required for headless execution.
|
||||||
|
|
||||||
142
ROADMAP.md
Normal file
142
ROADMAP.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Documentation maintenance
|
||||||
|
|
||||||
|
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated alongside roadmap-driving feature completion, priority shifts, and notable delivery milestones.
|
||||||
|
- Keep [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md) updated when roadmap items move from planned to delivered.
|
||||||
|
- When roadmap changes affect implementation guidance or deployment expectations, update the companion docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) in the same change set.
|
||||||
|
|
||||||
|
## Product direction
|
||||||
|
|
||||||
|
CODEXIUM is being built as a streamlined, modular manufacturing resource planning platform with strong branding controls, fast operational workflows, and a single-container deployment model that is simple to back up and upgrade.
|
||||||
|
|
||||||
|
This file tracks work that still needs to be completed. Shipped phase history and completed slices now live in [SHIPPED.md](D:/CODING/mrp-codex/SHIPPED.md).
|
||||||
|
|
||||||
|
## Near-term priority order
|
||||||
|
|
||||||
|
1. Project milestones, project rollups, and deeper project-side execution visibility
|
||||||
|
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
|
3. Dashboard KPI, alert, recent-activity, and exception-widget expansion
|
||||||
|
4. Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
||||||
|
|
||||||
|
## Active roadmap
|
||||||
|
|
||||||
|
### Platform and operational docs
|
||||||
|
|
||||||
|
- Keep the Windows Prisma migration workflow clearer and less fragile for local contributors
|
||||||
|
- Continue tightening backup, restore, and support-runbook guidance as operations maturity grows
|
||||||
|
- Preserve the single-container deployment path while improving diagnostics and supportability
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Expand `Dashboard` by modular panels rather than redesigning it into a different shell
|
||||||
|
- Add richer KPI widgets, alerts, recent-activity queues, and exception reporting
|
||||||
|
- Add deeper project, manufacturing, purchasing, shipping, and audit/system-health widgets
|
||||||
|
|
||||||
|
### CRM and master data
|
||||||
|
|
||||||
|
- Better seed/bootstrap strategy for non-development environments
|
||||||
|
- Additional CRM account-role depth if later sales/purchasing workflows need it
|
||||||
|
- More derived CRM rollups once downstream quote/order/purchasing/shipping data grows further
|
||||||
|
- Saved CRM filters and quick views
|
||||||
|
- Better hierarchy navigation between reseller parents and child accounts
|
||||||
|
- One-click contact actions for email and phone workflows
|
||||||
|
- Duplicate-account detection and merge workflow
|
||||||
|
- Cleaner attachment previews and richer record timelines
|
||||||
|
- More compact table controls for heavy CRM data-entry users
|
||||||
|
- CRM document rollups and broader account-role depth that were deferred until downstream modules matured
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
|
||||||
|
- Item master enrichment: categories, alternate part numbers, revisions, reorder settings, and broader sourcing metadata
|
||||||
|
- Faster keyboard-heavy item/BOM entry refinement beyond the current searchable pickers
|
||||||
|
- Better warehouse dashboards for on-hand, shortages, reservations, and recent movement
|
||||||
|
- BOM revision support and clearer where-used visibility
|
||||||
|
- Bulk item import/export and mass-update utilities
|
||||||
|
|
||||||
|
### Sales and purchasing
|
||||||
|
|
||||||
|
- Vendor exception handling for acknowledgements, invoice matching, receipt discrepancies, and related inbound follow-up
|
||||||
|
- Deeper carrier/commercial defaults where they improve order-entry speed
|
||||||
|
- Line duplication, drag ordering, and keyboard-first line editing
|
||||||
|
- Saved customer defaults for tax, freight, and commercial terms
|
||||||
|
- Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions
|
||||||
|
- Better totals breakdown visibility on list pages and detail pages
|
||||||
|
- Faster document cloning and quote-to-order style conversions across document types
|
||||||
|
|
||||||
|
### Shipping and logistics
|
||||||
|
|
||||||
|
- Partial shipment workflow and split-shipment visibility
|
||||||
|
- Better tracking-link UX and carrier-specific shortcuts
|
||||||
|
- Packing verification and ship-confirm checkpoints
|
||||||
|
- Shipment search by order, tracking, customer, and carrier from one screen
|
||||||
|
- Printer-friendly reprint/history actions for logistics documents
|
||||||
|
|
||||||
|
### Projects and program management
|
||||||
|
|
||||||
|
- Project document hub for drawings, support files, correspondence, and revision references
|
||||||
|
- Milestones, checkpoints, and non-manufacturing work packages for long-running execution tracking
|
||||||
|
- Project-level commercial, material, schedule, and delivery rollups
|
||||||
|
- Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication
|
||||||
|
- Project templates for repeatable build types
|
||||||
|
- Project-specific attachment bundles and revision snapshots
|
||||||
|
- One-screen project cockpit with commercial, material, schedule, and shipping summary
|
||||||
|
- Better cross-links between project, customer, order, shipment, and inventory records
|
||||||
|
- Project filtering by customer, owner, status, due date, and risk
|
||||||
|
- Project activity timeline and audit-friendly milestone history
|
||||||
|
|
||||||
|
### Manufacturing execution
|
||||||
|
|
||||||
|
- Work orders tied more explicitly to sales demand or internal build demand where appropriate
|
||||||
|
- Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
|
||||||
|
- Material consumption depth, WIP tracking, and execution traceability
|
||||||
|
- Labor and machine-time capture for production execution
|
||||||
|
- Manufacturing rollups for open work, blockers, shortages, and throughput
|
||||||
|
- Traveler/job packet output
|
||||||
|
- Partial completions and split-order execution visibility
|
||||||
|
- Better shortage and substitute-part handling
|
||||||
|
- Shop-floor quick actions and dense tablet-friendly execution views
|
||||||
|
- Rework / hold / scrap tracking
|
||||||
|
- Work-center dashboards and operator-focused queues
|
||||||
|
|
||||||
|
### Planning and scheduling
|
||||||
|
|
||||||
|
- Task dependencies, milestones, and progress updates
|
||||||
|
- Manufacturing calendar views and bottleneck visibility
|
||||||
|
- Labor and machine scheduling support
|
||||||
|
- Theme-compliant gantt customization for light/dark mode
|
||||||
|
- Collapsible schedule groupings and saved planner views
|
||||||
|
- Drag-and-drop rescheduling improvements
|
||||||
|
- Critical-path and overdue highlighting
|
||||||
|
- Capacity warnings for overloaded work centers
|
||||||
|
- Better mobile and tablet behavior for shop-floor lookups
|
||||||
|
- Faster filtering by project, customer, work center, and status
|
||||||
|
|
||||||
|
### Demand planning and supply generation
|
||||||
|
|
||||||
|
- Deeper planner drilldowns from demand source to buy/build action without re-keying data
|
||||||
|
- Better shortage and substitute-part guidance during planning review
|
||||||
|
- Saved planning views by customer, project, item family, and shortage state
|
||||||
|
- Time-phased supply recommendations with vendor lead times and build timing
|
||||||
|
|
||||||
|
### Security, audit, and operations maturity
|
||||||
|
|
||||||
|
- Admin diagnostics depth for permissions, migrations, storage, and PDF health
|
||||||
|
- Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
||||||
|
- More explicit environment validation on startup
|
||||||
|
- Backup verification and restore-drill guidance should keep expanding as the system grows
|
||||||
|
|
||||||
|
## Cross-cutting improvements
|
||||||
|
|
||||||
|
- Stronger validation and error reporting across all APIs
|
||||||
|
- More automated tests for auth, settings, files, PDFs, and workflow modules
|
||||||
|
- Better mobile behavior in module-level pages
|
||||||
|
- Ongoing responsive-density tuning for module-level layouts and data-entry screens
|
||||||
|
- Consistent document-template system shared by sales, purchasing, and shipping
|
||||||
|
- Clear upgrade path for future module additions without refactoring the app shell
|
||||||
|
|
||||||
|
## Revisit / Deferred Items
|
||||||
|
|
||||||
|
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
|
||||||
|
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
|
||||||
|
|
||||||
129
SHIPPED.md
Normal file
129
SHIPPED.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Shipped
|
||||||
|
|
||||||
|
This file tracks roadmap phases, slices, and major foundations that have already shipped. Remaining work lives in [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md).
|
||||||
|
|
||||||
|
## Foundation release
|
||||||
|
|
||||||
|
- Monorepo-style workspace with `client`, `server`, and `shared`
|
||||||
|
- React + Vite + Tailwind frontend shell
|
||||||
|
- Express + TypeScript backend shell
|
||||||
|
- Prisma + SQLite schema foundation with committed initial migration
|
||||||
|
- Local authentication with JWT-based session flow plus persisted session visibility and revocation
|
||||||
|
- RBAC permission model and protected routes
|
||||||
|
- Central Company Settings with runtime branding controls
|
||||||
|
- Light and dark mode theme system
|
||||||
|
- Local file attachment storage under `/app/data/uploads`
|
||||||
|
- Puppeteer PDF service foundation with branded company-profile preview
|
||||||
|
- CRM reference entities for customers and vendors
|
||||||
|
- CRM customer and vendor create/edit/detail workflows
|
||||||
|
- CRM search, filters, and persisted status tagging
|
||||||
|
- CRM contact-history timeline with authored notes, calls, emails, and meetings
|
||||||
|
- CRM shared file attachments on customer and vendor records, including delete support
|
||||||
|
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support
|
||||||
|
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
|
||||||
|
- Inventory item master, BOM, warehouse, and stock-location foundation
|
||||||
|
- Inventory transactions, on-hand tracking, and item attachments
|
||||||
|
- Inventory transfers, reservations, available-stock visibility, and work-order-driven material reservation automation
|
||||||
|
- Sales quotes and sales orders with commercial totals logic
|
||||||
|
- Purchase orders with vendor lookup, item lines, totals, and quick status actions
|
||||||
|
- Purchase-order line selection restricted to inventory items flagged as purchasable
|
||||||
|
- Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking
|
||||||
|
- Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline
|
||||||
|
- Shipping shipment records linked to sales orders
|
||||||
|
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
|
||||||
|
- Logistics attachments directly on shipment records
|
||||||
|
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||||
|
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||||
|
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
|
||||||
|
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
|
||||||
|
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||||
|
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||||
|
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
|
||||||
|
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
|
||||||
|
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
|
||||||
|
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
|
||||||
|
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
|
||||||
|
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
|
||||||
|
- Backup/restore guidance and exportable support bundles surfaced through the admin diagnostics workflow
|
||||||
|
- Backup verification checklist and restore-drill runbook surfaced through the admin diagnostics workflow
|
||||||
|
- Support-log viewing for startup warnings, HTTP failures, and server errors surfaced through the admin diagnostics workflow
|
||||||
|
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
|
||||||
|
- SKU-searchable BOM component selection for inventory-scale datasets
|
||||||
|
- Theme persistence fixes and denser responsive workspace layouts
|
||||||
|
- Startup brand-theme hydration so Company Settings colors and font persist correctly across refresh
|
||||||
|
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||||
|
- Live planning gantt timelines driven by project and manufacturing data
|
||||||
|
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||||
|
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||||
|
- Docker image validated locally with successful app startup and login flow
|
||||||
|
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
|
||||||
|
|
||||||
|
## Shipped roadmap phases
|
||||||
|
|
||||||
|
### Phase 3: Sales and purchasing documents
|
||||||
|
|
||||||
|
- Sales approval stamps and automatic revision history on quotes and sales orders
|
||||||
|
- Purchase-order supporting documents through the shared attachment pipeline
|
||||||
|
- Vendor-detail purchasing visibility for recent purchase-order activity
|
||||||
|
|
||||||
|
### Phase 5: Projects and program management
|
||||||
|
|
||||||
|
- Project records with customer linkage, status, owner, priority, due dates, and notes
|
||||||
|
- Project-to-quote, sales-order, and shipment linkage for delivery context
|
||||||
|
- Project attachments through the shared file pipeline
|
||||||
|
- Project list/detail/create/edit flows and dashboard visibility
|
||||||
|
|
||||||
|
### Phase 6: Manufacturing execution
|
||||||
|
|
||||||
|
- Work orders tied to manufactured or assembly items, with optional project linkage
|
||||||
|
- BOM-based material requirement visibility from the work-order record
|
||||||
|
- Material issue posting that creates real inventory issue transactions
|
||||||
|
- Production completion posting that creates finished-goods receipt transactions
|
||||||
|
- Work-order list/detail/create/edit flows, attachments, and dashboard visibility
|
||||||
|
|
||||||
|
### Phase 7: Planning and scheduling
|
||||||
|
|
||||||
|
- Live gantt schedule backed by active projects and open manufacturing work orders
|
||||||
|
- Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility
|
||||||
|
- Planning exception queue for overdue or at-risk project/manufacturing schedule items
|
||||||
|
|
||||||
|
### Phase 8: Demand planning and supply generation
|
||||||
|
|
||||||
|
- Sales-order demand planning from approved or active demand records
|
||||||
|
- Multi-level BOM explosion from sales-order lines through manufactured and assembly children
|
||||||
|
- Netting against available stock, active reservations, open work orders, and open purchase orders
|
||||||
|
- Build and buy recommendations surfaced directly from the sales-order workflow
|
||||||
|
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
|
||||||
|
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
|
||||||
|
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
|
||||||
|
- Pegged work-order and purchase-order supply links back to originating sales demand
|
||||||
|
- Planning recommendations now reduce against already-linked draft/open supply to avoid duplicate WO/PO generation
|
||||||
|
|
||||||
|
### Phase 9: Security, audit, and operations maturity
|
||||||
|
|
||||||
|
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
|
||||||
|
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
|
||||||
|
- Expanded role-management UI with account creation, activation, role assignment, and permission administration
|
||||||
|
- Persisted auth-session tracking with admin visibility into active, expired, and revoked sign-ins
|
||||||
|
- Server-side logout and admin session revocation for JWT-backed access
|
||||||
|
- Session review filtering, unusual-access cues, diagnostics rollups, and startup pruning of stale expired/revoked auth sessions
|
||||||
|
- Shared destructive-action confirmation and recovery messaging for admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse/form-editor, and attachment workflows
|
||||||
|
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
|
||||||
|
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
|
||||||
|
- Backup/restore guidance, support-bundle exports, and support-log viewing surfaced through the admin diagnostics workflow
|
||||||
|
|
||||||
|
## Shipped quality-of-life slices
|
||||||
|
|
||||||
|
- Purchase-order item entry restricted to purchasable inventory items only
|
||||||
|
- Inventory transfers between warehouses and locations
|
||||||
|
- Manual and work-order-driven inventory reservations
|
||||||
|
- Reserved and available stock visibility on inventory item detail and stock-by-location views
|
||||||
|
- Searchable operational pickers for customers, vendors, SKUs, BOM components, and other dense record selectors
|
||||||
|
- Route-level lazy loading and vendor chunking for a lighter initial client payload
|
||||||
|
- Persisted auth-session review filtering and admin-side access review cues
|
||||||
|
- Destructive-action confirmation coverage expanded into project customer/document unlinking and form-row removals in sales, purchasing, inventory, and warehouse editors
|
||||||
|
- Support-log filtering, retention cleanup, and richer filtered support-bundle exports in admin diagnostics
|
||||||
|
- Inventory thumbnail staging on item create/edit plus dedicated detail-page thumbnail display
|
||||||
|
- Demand-planning PO draft generation no longer applies mismatched sales-order line links to BOM child buy recommendations
|
||||||
|
- User-management saves now accept blank-password edits for existing users and surface errors in-page
|
||||||
|
|
||||||
55
STRUCTURE.md
Normal file
55
STRUCTURE.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
## Documentation maintenance
|
||||||
|
|
||||||
|
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated when structural or implementation changes materially affect shipped behavior.
|
||||||
|
- If structure guidance changes, update the related source-of-truth docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), and [AGENTS.md](D:/CODING/mrp-codex/AGENTS.md) as needed.
|
||||||
|
|
||||||
|
## Top-level layout
|
||||||
|
|
||||||
|
- `client/`: frontend application
|
||||||
|
- `server/`: backend application
|
||||||
|
- `shared/`: shared TypeScript contracts, permissions, and utility types
|
||||||
|
- `Dockerfile`: production container build
|
||||||
|
- `docker-entrypoint.sh`: migration-aware startup script
|
||||||
|
|
||||||
|
## Frontend rules
|
||||||
|
|
||||||
|
- Organize code by domain under `src/modules`.
|
||||||
|
- Keep app-shell concerns in `src/app`.
|
||||||
|
- Keep reusable UI primitives in `src/components`.
|
||||||
|
- Theme state and brand tokens belong in `src/theme`.
|
||||||
|
- PDF screen components must remain separate from API-rendered document templates.
|
||||||
|
- Treat `src/modules/dashboard` as a long-lived operational module. New high-level KPI, alert, queue, and shortcut surfaces should compose into it rather than spawning disconnected landing pages.
|
||||||
|
- Any non-filter lookup UI must be implemented as a searchable picker or autocomplete; do not use long static dropdowns for operational datasets such as items, customers, vendors, or document-linked records.
|
||||||
|
- Inventory items expose both cost and sell price. Downstream sales document entry should default from the item price field rather than requiring duplicate price maintenance.
|
||||||
|
- Future vendor-facing purchasing flows should follow the same searchable-lookup rule and shared document/totals model already used by sales.
|
||||||
|
- Purchase-order item pickers must only surface inventory items flagged as purchasable.
|
||||||
|
- Shipping, sales, and future purchasing PDFs should be rendered through the backend documents module and shared Puppeteer pipeline rather than ad hoc frontend-only exports.
|
||||||
|
- Preserve the current dense operations UI style on active module pages: compact controls, tighter card padding, and shorter empty states unless a screen has a clear reason to be more spacious.
|
||||||
|
- Treat `projects` as its own long-lived domain under both client and server. It should continue integrating with CRM, sales, inventory, purchasing, shipping, and planning rather than living inside only one of those modules.
|
||||||
|
- Treat `manufacturing` as a separate long-lived domain from `projects`; work orders, routings, labor capture, WIP, and shop-floor execution should not be modeled only as project fields.
|
||||||
|
- Treat `planning` as the scheduling/visibility layer that consumes project and manufacturing data rather than replacing either domain.
|
||||||
|
- When adding a new top-level module to the shell, add a lightweight SVG icon in the navigation config so desktop and mobile nav stay aligned.
|
||||||
|
|
||||||
|
## Backend rules
|
||||||
|
|
||||||
|
- Organize domain modules under `src/modules/<domain>`.
|
||||||
|
- Keep HTTP routers thin; place business logic in services.
|
||||||
|
- Centralize Prisma access, auth middleware, persisted session helpers, file storage utilities, startup validation, and support logging in `src/lib`.
|
||||||
|
- Store persistence-related constants under `src/config`.
|
||||||
|
- Serve the built frontend from the API layer in production.
|
||||||
|
|
||||||
|
## Shared package rules
|
||||||
|
|
||||||
|
- Place cross-app DTOs, permission keys, enums, and document interfaces in `shared/src`.
|
||||||
|
- Keep shared code free of runtime framework dependencies.
|
||||||
|
|
||||||
|
## Adding a new domain
|
||||||
|
|
||||||
|
1. Add backend routes, service, and repository/module files under `server/src/modules/<domain>`.
|
||||||
|
2. Add Prisma models and a migration if the module needs persistence.
|
||||||
|
3. Add permission keys in `shared/src/auth`.
|
||||||
|
4. Add frontend route/module under `client/src/modules/<domain>`.
|
||||||
|
5. Register navigation and route guards through the app shell without refactoring existing modules.
|
||||||
|
|
||||||
195
UNRAID.md
Normal file
195
UNRAID.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Unraid Install Guide
|
||||||
|
|
||||||
|
## Documentation maintenance
|
||||||
|
|
||||||
|
- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated when deployment behavior, startup flow, persistence expectations, or operator-facing install steps materially change.
|
||||||
|
- If Unraid deployment guidance changes, update the companion project docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), and [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) in the same change set when relevant.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This guide explains how to deploy CODEXIUM on an Unraid server using the Unraid Docker GUI rather than command-line Docker management.
|
||||||
|
|
||||||
|
## What this container expects
|
||||||
|
|
||||||
|
- One published web port for the application
|
||||||
|
- One persistent app data path mounted to `/app/data`
|
||||||
|
- Environment variables for the server port, JWT secret, and SQLite path
|
||||||
|
- Automatic Prisma migration execution during container startup
|
||||||
|
|
||||||
|
CODEXIUM stores both the SQLite database and uploaded files inside the same persistent container path:
|
||||||
|
|
||||||
|
- database: `/app/data/prisma/app.db`
|
||||||
|
- uploads: `/app/data/uploads`
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- Build and push the image to a registry your Unraid server can access, or import it manually if you are self-hosting images
|
||||||
|
- Choose an Unraid share for persistent app data, for example:
|
||||||
|
- `/mnt/user/appdata/codexium`
|
||||||
|
- Choose the port you want to expose in Unraid, for example:
|
||||||
|
- host port `3000`
|
||||||
|
- container port `3000`
|
||||||
|
|
||||||
|
## Recommended Unraid paths
|
||||||
|
|
||||||
|
- App data share: `/mnt/user/appdata/codexium`
|
||||||
|
- Optional backup target: `/mnt/user/backups/codexium`
|
||||||
|
|
||||||
|
Keep the entire app data folder together so backups capture both the SQLite database and file attachments.
|
||||||
|
|
||||||
|
## Add the container in the Unraid GUI
|
||||||
|
|
||||||
|
### 1. Open the Docker tab
|
||||||
|
|
||||||
|
- In Unraid, go to `Docker`
|
||||||
|
- Click `Add Container`
|
||||||
|
|
||||||
|
### 2. Basic container settings
|
||||||
|
|
||||||
|
Use these values as the starting point:
|
||||||
|
|
||||||
|
- Name: `mrp-codex`
|
||||||
|
- Repository: your built image name, for example `your-registry/mrp-codex:latest`
|
||||||
|
- Network Type: `bridge`
|
||||||
|
- Console shell command: leave default
|
||||||
|
- Privileged: `Off`
|
||||||
|
|
||||||
|
### 3. Port mapping
|
||||||
|
|
||||||
|
Add a port mapping:
|
||||||
|
|
||||||
|
- Name: `WebUI`
|
||||||
|
- Container Port: `3000`
|
||||||
|
- Host Port: `3000`
|
||||||
|
- Connection Type: `TCP`
|
||||||
|
|
||||||
|
If port `3000` is already used on the Unraid host, change only the host port.
|
||||||
|
|
||||||
|
### 4. Path mapping
|
||||||
|
|
||||||
|
Add a single path mapping:
|
||||||
|
|
||||||
|
- Name: `data`
|
||||||
|
- Container Path: `/app/data`
|
||||||
|
- Host Path: `/mnt/user/appdata/mrp-codex`
|
||||||
|
- Access Mode: `Read/Write`
|
||||||
|
|
||||||
|
This is the critical mapping. Do not split the database and uploads into separate container paths unless you also update the app configuration.
|
||||||
|
|
||||||
|
### 5. Environment variables
|
||||||
|
|
||||||
|
Add these variables in the Unraid GUI:
|
||||||
|
|
||||||
|
- `NODE_ENV` = `production`
|
||||||
|
- `PORT` = `3000`
|
||||||
|
- `JWT_SECRET` = `replace-with-a-long-random-secret`
|
||||||
|
- `DATA_DIR` = `/app/data`
|
||||||
|
- `DATABASE_URL` = `file:../../data/prisma/app.db`
|
||||||
|
- `PUPPETEER_EXECUTABLE_PATH` = `/usr/bin/chromium`
|
||||||
|
- `CLIENT_ORIGIN` = `http://YOUR-UNRAID-IP:3000`
|
||||||
|
|
||||||
|
If you use a reverse proxy and external hostname, set `CLIENT_ORIGIN` to the final browser URL instead, for example:
|
||||||
|
|
||||||
|
- `CLIENT_ORIGIN` = `https://mrp.yourdomain.com`
|
||||||
|
|
||||||
|
### 6. Optional first-admin overrides
|
||||||
|
|
||||||
|
You can set the seeded admin login explicitly on first deployment:
|
||||||
|
|
||||||
|
- `ADMIN_EMAIL` = `admin@yourcompany.com`
|
||||||
|
- `ADMIN_PASSWORD` = `use-a-strong-initial-password`
|
||||||
|
|
||||||
|
If you do not set them, the defaults from the app bootstrapping logic are used.
|
||||||
|
|
||||||
|
## First start behavior
|
||||||
|
|
||||||
|
On first container start, the entrypoint will:
|
||||||
|
|
||||||
|
1. Ensure `/app/data/prisma` and `/app/data/uploads` exist
|
||||||
|
2. Run `/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma`
|
||||||
|
3. Start the Node.js server
|
||||||
|
|
||||||
|
The frontend is served by the same container as the API, so there is only one exposed web port.
|
||||||
|
|
||||||
|
## First login
|
||||||
|
|
||||||
|
If you did not override the admin credentials with environment variables, use:
|
||||||
|
|
||||||
|
- email: `admin@mrp.local`
|
||||||
|
- password: `ChangeMe123!`
|
||||||
|
|
||||||
|
Change the admin password after first login if you keep the default bootstrap user.
|
||||||
|
|
||||||
|
## Updating the container in Unraid
|
||||||
|
|
||||||
|
When you publish a new image:
|
||||||
|
|
||||||
|
1. Open the `Docker` tab
|
||||||
|
2. Apply the update from the Unraid GUI
|
||||||
|
3. Start the container
|
||||||
|
|
||||||
|
Because CODEXIUM runs `prisma migrate deploy` during startup, committed migrations are applied automatically before the app launches.
|
||||||
|
|
||||||
|
This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, manufacturing work orders, audit tooling, and persisted auth sessions. Let the container complete startup migrations before testing new screens.
|
||||||
|
|
||||||
|
## Backup guidance
|
||||||
|
|
||||||
|
Back up the host directory mapped to `/app/data`, typically:
|
||||||
|
|
||||||
|
- `/mnt/user/appdata/mrp-codex`
|
||||||
|
|
||||||
|
That captures:
|
||||||
|
|
||||||
|
- the SQLite database
|
||||||
|
- uploaded CAD drawings and supporting files
|
||||||
|
- logos and other persisted attachments
|
||||||
|
|
||||||
|
For consistent backups, stop the container before copying the appdata directory.
|
||||||
|
|
||||||
|
## Reverse proxy notes
|
||||||
|
|
||||||
|
If you place CODEXIUM behind Nginx Proxy Manager, Traefik, or another reverse proxy:
|
||||||
|
|
||||||
|
- keep the Unraid container on `bridge` or your preferred proxy-compatible network
|
||||||
|
- point the proxy at the Unraid host IP and chosen host port
|
||||||
|
- set `CLIENT_ORIGIN` to the final external URL
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container starts then exits
|
||||||
|
|
||||||
|
Check the container logs in Unraid. Common causes:
|
||||||
|
|
||||||
|
- invalid `DATABASE_URL`
|
||||||
|
- missing write access to the host path mapped to `/app/data`
|
||||||
|
- bad or missing environment values
|
||||||
|
|
||||||
|
### PDFs fail to generate
|
||||||
|
|
||||||
|
The image already includes Chromium and the required runtime libraries. If PDF generation still fails:
|
||||||
|
|
||||||
|
- confirm `PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium`
|
||||||
|
- inspect the container logs for Puppeteer launch errors
|
||||||
|
|
||||||
|
### Application boots but data does not persist
|
||||||
|
|
||||||
|
Verify the `/app/data` path mapping in Unraid. If `/app/data` is not mapped to a persistent Unraid share, the database and uploads will be lost when the container is recreated.
|
||||||
|
|
||||||
|
### Browser login or CORS issues
|
||||||
|
|
||||||
|
Set `CLIENT_ORIGIN` to the exact URL used by the browser, including protocol and port.
|
||||||
|
|
||||||
|
## Suggested Unraid template summary
|
||||||
|
|
||||||
|
- Repository: `your-registry/mrp-codex:latest`
|
||||||
|
- Network Type: `bridge`
|
||||||
|
- Port: `3000` -> `3000`
|
||||||
|
- Path: `/mnt/user/appdata/mrp-codex` -> `/app/data`
|
||||||
|
- Env: `NODE_ENV=production`
|
||||||
|
- Env: `PORT=3000`
|
||||||
|
- Env: `JWT_SECRET=<strong secret>`
|
||||||
|
- Env: `DATA_DIR=/app/data`
|
||||||
|
- Env: `DATABASE_URL=file:../../data/prisma/app.db`
|
||||||
|
- Env: `PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium`
|
||||||
|
- Env: `CLIENT_ORIGIN=http://YOUR-UNRAID-IP:3000`
|
||||||
|
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MRP Codex</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
32
client/package.json
Normal file
32
client/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@mrp/client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "tsc -b --pretty false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mrp/shared": "0.1.0",
|
||||||
|
"@svar-ui/react-gantt": "^2.5.2",
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.9.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
client/postcss.config.cjs
Normal file
7
client/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
76
client/src/auth/AuthProvider.tsx
Normal file
76
client/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { AuthUser } from "@mrp/shared";
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
token: string | null;
|
||||||
|
user: AuthUser | null;
|
||||||
|
isReady: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
const tokenKey = "mrp.auth.token";
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(() => window.localStorage.getItem(tokenKey));
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setUser(null);
|
||||||
|
setIsReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.me(token)
|
||||||
|
.then((nextUser) => {
|
||||||
|
setUser(nextUser);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
window.localStorage.removeItem(tokenKey);
|
||||||
|
setToken(null);
|
||||||
|
})
|
||||||
|
.finally(() => setIsReady(true));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isReady,
|
||||||
|
async login(email, password) {
|
||||||
|
const result = await api.login({ email, password });
|
||||||
|
setToken(result.token);
|
||||||
|
setUser(result.user);
|
||||||
|
window.localStorage.setItem(tokenKey, result.token);
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await api.logout(token);
|
||||||
|
} catch {
|
||||||
|
// Clearing local auth state still signs the user out on the client.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.localStorage.removeItem(tokenKey);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[token, user, isReady]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
257
client/src/components/AppShell.tsx
Normal file
257
client/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../auth/AuthProvider";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ to: "/", label: "Dashboard", icon: <DashboardIcon /> },
|
||||||
|
{ to: "/settings/company", label: "Company Settings", icon: <CompanyIcon /> },
|
||||||
|
{ to: "/crm/customers", label: "Customers", icon: <CustomersIcon /> },
|
||||||
|
{ to: "/crm/vendors", label: "Vendors", icon: <VendorsIcon /> },
|
||||||
|
{ to: "/inventory/items", label: "Inventory", icon: <InventoryIcon /> },
|
||||||
|
{ to: "/inventory/warehouses", label: "Warehouses", icon: <WarehouseIcon /> },
|
||||||
|
{ to: "/sales/quotes", label: "Quotes", icon: <QuoteIcon /> },
|
||||||
|
{ to: "/sales/orders", label: "Sales Orders", icon: <SalesOrderIcon /> },
|
||||||
|
{ to: "/purchasing/orders", label: "Purchase Orders", icon: <PurchaseOrderIcon /> },
|
||||||
|
{ to: "/shipping/shipments", label: "Shipments", icon: <ShipmentIcon /> },
|
||||||
|
{ to: "/projects", label: "Projects", icon: <ProjectsIcon /> },
|
||||||
|
{ to: "/manufacturing/work-orders", label: "Manufacturing", icon: <ManufacturingIcon /> },
|
||||||
|
{ to: "/planning/gantt", label: "Gantt", icon: <GanttIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function NavIcon({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M4 4h7v7H4z" />
|
||||||
|
<path d="M13 4h7v4h-7z" />
|
||||||
|
<path d="M13 10h7v10h-7z" />
|
||||||
|
<path d="M4 13h7v7H4z" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M4 20h16" />
|
||||||
|
<path d="M6 20V8l6-4 6 4v12" />
|
||||||
|
<path d="M9 12h.01" />
|
||||||
|
<path d="M15 12h.01" />
|
||||||
|
<path d="M12 20v-4" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomersIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
||||||
|
<path d="M16 13a2.5 2.5 0 1 0 0-5" />
|
||||||
|
<path d="M3.5 19a4.5 4.5 0 0 1 9 0" />
|
||||||
|
<path d="M14 18a3.5 3.5 0 0 1 6 0" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorsIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M4 20h16" />
|
||||||
|
<path d="M6 20V7h12v13" />
|
||||||
|
<path d="M9 10h.01" />
|
||||||
|
<path d="M12 10h.01" />
|
||||||
|
<path d="M15 10h.01" />
|
||||||
|
<path d="M9 14h.01" />
|
||||||
|
<path d="M12 14h.01" />
|
||||||
|
<path d="M15 14h.01" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M4 8l8-4 8 4-8 4-8-4Z" />
|
||||||
|
<path d="M4 8v8l8 4 8-4V8" />
|
||||||
|
<path d="M12 12v8" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarehouseIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M3 10 12 4l9 6" />
|
||||||
|
<path d="M5 10v10h14V10" />
|
||||||
|
<path d="M9 20v-5h6v5" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuoteIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M7 4h8l4 4v12H7z" />
|
||||||
|
<path d="M15 4v4h4" />
|
||||||
|
<path d="M10 12h6" />
|
||||||
|
<path d="M10 16h4" />
|
||||||
|
<path d="M5 8H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h8" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SalesOrderIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M7 4h10l3 3v13H7z" />
|
||||||
|
<path d="M17 4v3h3" />
|
||||||
|
<path d="M10 11h7" />
|
||||||
|
<path d="M10 15h7" />
|
||||||
|
<path d="M10 19h5" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PurchaseOrderIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M7 4h10l3 3v13H7z" />
|
||||||
|
<path d="M17 4v3h3" />
|
||||||
|
<path d="M10 11h7" />
|
||||||
|
<path d="M10 15h4" />
|
||||||
|
<path d="M15.5 17.5 18 20l3-4" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShipmentIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M3 8h11v8H3z" />
|
||||||
|
<path d="M14 11h3l3 3v2h-6" />
|
||||||
|
<path d="M7 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
||||||
|
<path d="M17 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GanttIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M4 6h5" />
|
||||||
|
<path d="M4 12h16" />
|
||||||
|
<path d="M4 18h10" />
|
||||||
|
<rect x="10" y="4" width="7" height="4" rx="1.5" />
|
||||||
|
<rect x="7" y="16" width="9" height="4" rx="1.5" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectsIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<path d="M5 6h6" />
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
<path d="M5 18h8" />
|
||||||
|
<rect x="12" y="4" width="7" height="4" rx="1.5" />
|
||||||
|
<rect x="9" y="16" width="9" height="4" rx="1.5" />
|
||||||
|
<path d="M12 8v8" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManufacturingIcon() {
|
||||||
|
return (
|
||||||
|
<NavIcon>
|
||||||
|
<circle cx="8" cy="16" r="2" />
|
||||||
|
<circle cx="16" cy="16" r="2" />
|
||||||
|
<path d="M8 14V8l4-2 4 2v6" />
|
||||||
|
<path d="M12 10h6" />
|
||||||
|
<path d="M18 8v4" />
|
||||||
|
</NavIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-4 py-5 xl:px-6 2xl:px-8">
|
||||||
|
<div className="mx-auto flex w-full max-w-[1760px] gap-3 2xl:gap-4">
|
||||||
|
<aside className="hidden w-72 shrink-0 flex-col rounded-[22px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur md:flex 2xl:w-80">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="mt-6 space-y-2">
|
||||||
|
{links.map((link) => (
|
||||||
|
<NavLink
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 rounded-2xl px-2 py-2 text-sm font-semibold transition ${
|
||||||
|
isActive ? "bg-brand text-white" : "text-text hover:bg-page"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{link.icon}
|
||||||
|
{link.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mt-auto space-y-3">
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/70 p-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">Theme</p>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/70 p-4">
|
||||||
|
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
|
||||||
|
<p className="text-xs text-muted">{user?.email}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void logout();
|
||||||
|
}}
|
||||||
|
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<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">
|
||||||
|
{links.map((link) => (
|
||||||
|
<NavLink
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`inline-flex whitespace-nowrap items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition ${
|
||||||
|
isActive ? "bg-brand text-white" : "bg-page/70 text-text"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{link.icon}
|
||||||
|
{link.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mb-4 md:hidden">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
client/src/components/ConfirmActionDialog.tsx
Normal file
108
client/src/components/ConfirmActionDialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface ConfirmActionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact?: string;
|
||||||
|
recovery?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
intent?: "danger" | "primary";
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
isConfirming?: boolean;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmActionDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
impact,
|
||||||
|
recovery,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
cancelLabel = "Cancel",
|
||||||
|
intent = "danger",
|
||||||
|
confirmationLabel,
|
||||||
|
confirmationValue,
|
||||||
|
isConfirming = false,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: ConfirmActionDialogProps) {
|
||||||
|
const [typedValue, setTypedValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTypedValue("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
||||||
|
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
|
||||||
|
const confirmButtonClass =
|
||||||
|
intent === "danger"
|
||||||
|
? "bg-red-600 text-white hover:bg-red-700"
|
||||||
|
: "bg-brand text-white hover:brightness-110";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
|
||||||
|
<div className="w-full max-w-xl rounded-[20px] border border-line/70 bg-surface p-5 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
|
||||||
|
{impact ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
|
||||||
|
<span className="mt-1 block">{impact}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{recovery ? (
|
||||||
|
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
|
||||||
|
<span className="mt-1 block">{recovery}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{requiresTypedConfirmation ? (
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">
|
||||||
|
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={typedValue}
|
||||||
|
onChange={(event) => setTypedValue(event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isConfirming}
|
||||||
|
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onConfirm();
|
||||||
|
}}
|
||||||
|
disabled={isConfirmDisabled}
|
||||||
|
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
|
||||||
|
>
|
||||||
|
{isConfirming ? "Working..." : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type RevisionOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
meta: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComparisonField = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComparisonLine = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
quantity: string;
|
||||||
|
unitLabel: string;
|
||||||
|
amountLabel: string;
|
||||||
|
totalLabel?: string;
|
||||||
|
extraLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComparisonDocument = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
status: string;
|
||||||
|
metaFields: ComparisonField[];
|
||||||
|
totalFields: ComparisonField[];
|
||||||
|
notes: string;
|
||||||
|
lines: ComparisonLine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffRow = {
|
||||||
|
key: string;
|
||||||
|
status: "ADDED" | "REMOVED" | "CHANGED";
|
||||||
|
left?: ComparisonLine;
|
||||||
|
right?: ComparisonLine;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildLineMap(lines: ComparisonLine[]) {
|
||||||
|
return new Map(lines.map((line) => [line.key, line]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineSignature(line?: ComparisonLine) {
|
||||||
|
if (!line) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [line.title, line.subtitle ?? "", line.quantity, line.unitLabel, line.amountLabel, line.totalLabel ?? "", line.extraLabel ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiffRows(left: ComparisonDocument, right: ComparisonDocument): DiffRow[] {
|
||||||
|
const leftLines = buildLineMap(left.lines);
|
||||||
|
const rightLines = buildLineMap(right.lines);
|
||||||
|
const orderedKeys = [...new Set([...left.lines.map((line) => line.key), ...right.lines.map((line) => line.key)])];
|
||||||
|
const rows: DiffRow[] = [];
|
||||||
|
|
||||||
|
for (const key of orderedKeys) {
|
||||||
|
const leftLine = leftLines.get(key);
|
||||||
|
const rightLine = rightLines.get(key);
|
||||||
|
|
||||||
|
if (leftLine && !rightLine) {
|
||||||
|
rows.push({ key, status: "REMOVED", left: leftLine });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!leftLine && rightLine) {
|
||||||
|
rows.push({ key, status: "ADDED", right: rightLine });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineSignature(leftLine) !== lineSignature(rightLine)) {
|
||||||
|
rows.push({ key, status: "CHANGED", left: leftLine, right: rightLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): Array<{ label: string; leftValue: string; rightValue: string }> {
|
||||||
|
const rightByLabel = new Map(right.map((field) => [field.label, field.value]));
|
||||||
|
|
||||||
|
return left.flatMap((field) => {
|
||||||
|
const rightValue = rightByLabel.get(field.label);
|
||||||
|
if (rightValue == null || rightValue === field.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: field.label,
|
||||||
|
leftValue: field.value,
|
||||||
|
rightValue,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
|
||||||
|
return (
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
|
<h4 className="mt-2 text-base font-bold text-text">{document.title}</h4>
|
||||||
|
<p className="mt-1 text-sm text-muted">{document.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||||
|
{document.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{document.metaFields.map((field) => (
|
||||||
|
<div key={`${label}-${field.label}`}>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-text">{field.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{document.totalFields.map((field) => (
|
||||||
|
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentRevisionComparison({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
currentLabel,
|
||||||
|
currentDocument,
|
||||||
|
revisions,
|
||||||
|
getRevisionDocument,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
currentLabel: string;
|
||||||
|
currentDocument: ComparisonDocument;
|
||||||
|
revisions: RevisionOption[];
|
||||||
|
getRevisionDocument: (revisionId: string | "current") => ComparisonDocument;
|
||||||
|
}) {
|
||||||
|
const [leftRevisionId, setLeftRevisionId] = useState<string | "current">(revisions[0]?.id ?? "current");
|
||||||
|
const [rightRevisionId, setRightRevisionId] = useState<string | "current">("current");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLeftRevisionId((current) => (current === "current" || revisions.some((revision) => revision.id === current) ? current : revisions[0]?.id ?? "current"));
|
||||||
|
}, [revisions]);
|
||||||
|
|
||||||
|
const leftDocument = getRevisionDocument(leftRevisionId);
|
||||||
|
const rightDocument = getRevisionDocument(rightRevisionId);
|
||||||
|
const diffRows = buildDiffRows(leftDocument, rightDocument);
|
||||||
|
const metaChanges = buildFieldChanges(leftDocument.metaFields, rightDocument.metaFields);
|
||||||
|
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{title}</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block min-w-[220px]">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</span>
|
||||||
|
<select
|
||||||
|
value={leftRevisionId}
|
||||||
|
onChange={(event) => setLeftRevisionId(event.target.value as string | "current")}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{revisions.map((revision) => (
|
||||||
|
<option key={revision.id} value={revision.id}>
|
||||||
|
{revision.label} | {revision.meta}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block min-w-[220px]">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</span>
|
||||||
|
<select
|
||||||
|
value={rightRevisionId}
|
||||||
|
onChange={(event) => setRightRevisionId(event.target.value as string | "current")}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
<option value="current">{currentLabel}</option>
|
||||||
|
{revisions.map((revision) => (
|
||||||
|
<option key={revision.id} value={revision.id}>
|
||||||
|
{revision.label} | {revision.meta}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
<ComparisonCard label="Baseline" document={leftDocument} />
|
||||||
|
<ComparisonCard label="Compare To" document={rightDocument} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
|
||||||
|
{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-4 space-y-3">
|
||||||
|
{[...metaChanges, ...totalChanges].map((change) => (
|
||||||
|
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
|
||||||
|
<div className="mt-2 text-sm text-text">
|
||||||
|
{change.leftValue} {"->"} {change.rightValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||||
|
<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="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
|
||||||
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
|
||||||
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
|
||||||
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{diffRows.length === 0 ? (
|
||||||
|
<div className="mt-4 text-sm text-muted">No line-level changes between the selected revisions.</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{diffRows.map((row) => (
|
||||||
|
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-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>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</div>
|
||||||
|
<div className="mt-1 text-sm text-text">
|
||||||
|
{row.left ? `${row.left.quantity} | ${row.left.amountLabel}${row.left.totalLabel ? ` | ${row.left.totalLabel}` : ""}` : "Not present"}
|
||||||
|
</div>
|
||||||
|
{row.left?.subtitle ? <div className="mt-1 text-xs text-muted">{row.left.subtitle}</div> : null}
|
||||||
|
{row.left?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.left.extraLabel}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</div>
|
||||||
|
<div className="mt-1 text-sm text-text">
|
||||||
|
{row.right ? `${row.right.quantity} | ${row.right.amountLabel}${row.right.totalLabel ? ` | ${row.right.totalLabel}` : ""}` : "Not present"}
|
||||||
|
</div>
|
||||||
|
{row.right?.subtitle ? <div className="mt-1 text-xs text-muted">{row.right.subtitle}</div> : null}
|
||||||
|
{row.right?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.right.extraLabel}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
client/src/components/FileAttachmentsPanel.tsx
Normal file
221
client/src/components/FileAttachmentsPanel.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import type { FileAttachmentDto } from "@mrp/shared";
|
||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "./ConfirmActionDialog";
|
||||||
|
|
||||||
|
interface FileAttachmentsPanelProps {
|
||||||
|
ownerType: string;
|
||||||
|
ownerId: string;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
onAttachmentCountChange?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(sizeBytes: number) {
|
||||||
|
if (sizeBytes < 1024) {
|
||||||
|
return `${sizeBytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sizeBytes < 1024 * 1024) {
|
||||||
|
return `${(sizeBytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileAttachmentsPanel({
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
emptyMessage,
|
||||||
|
onAttachmentCountChange,
|
||||||
|
}: FileAttachmentsPanelProps) {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]);
|
||||||
|
const [status, setStatus] = useState("Loading attachments...");
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
|
||||||
|
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
|
||||||
|
|
||||||
|
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||||
|
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !canReadFiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getAttachments(token, ownerType, ownerId)
|
||||||
|
.then((nextAttachments) => {
|
||||||
|
setAttachments(nextAttachments);
|
||||||
|
onAttachmentCountChange?.(nextAttachments.length);
|
||||||
|
setStatus(nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load attachments.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [canReadFiles, onAttachmentCountChange, ownerId, ownerType, token]);
|
||||||
|
|
||||||
|
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file || !token || !canWriteFiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setStatus("Uploading attachment...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachment = await api.uploadFile(token, file, ownerType, ownerId);
|
||||||
|
setAttachments((current) => {
|
||||||
|
const nextAttachments = [attachment, ...current];
|
||||||
|
onAttachmentCountChange?.(nextAttachments.length);
|
||||||
|
return nextAttachments;
|
||||||
|
});
|
||||||
|
setStatus("Attachment uploaded.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to upload attachment.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpen(attachment: FileAttachmentDto) {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await api.getFileContentBlob(token, attachment.id);
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||||
|
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to open attachment.";
|
||||||
|
setStatus(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(attachment: FileAttachmentDto) {
|
||||||
|
if (!token || !canWriteFiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingAttachmentId(attachment.id);
|
||||||
|
setStatus(`Deleting ${attachment.originalName}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteAttachment(token, attachment.id);
|
||||||
|
setAttachments((current) => {
|
||||||
|
const nextAttachments = current.filter((item) => item.id !== attachment.id);
|
||||||
|
onAttachmentCountChange?.(nextAttachments.length);
|
||||||
|
return nextAttachments;
|
||||||
|
});
|
||||||
|
setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setDeletingAttachmentId(null);
|
||||||
|
setAttachmentPendingDelete(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">{title}</h4>
|
||||||
|
<p className="mt-2 text-sm text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
{canWriteFiles ? (
|
||||||
|
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
|
{isUploading ? "Uploading..." : "Upload file"}
|
||||||
|
<input className="hidden" type="file" onChange={handleUpload} disabled={isUploading} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{!canReadFiles ? (
|
||||||
|
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||||
|
You do not have permission to view file attachments.
|
||||||
|
</div>
|
||||||
|
) : 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">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="flex flex-col gap-2 rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
{attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpen(attachment)}
|
||||||
|
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
{canWriteFiles ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAttachmentPendingDelete(attachment)}
|
||||||
|
disabled={deletingAttachmentId === attachment.id}
|
||||||
|
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
{deletingAttachmentId === attachment.id ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={attachmentPendingDelete != null}
|
||||||
|
title="Delete attachment"
|
||||||
|
description={
|
||||||
|
attachmentPendingDelete
|
||||||
|
? `Delete ${attachmentPendingDelete.originalName} from this record.`
|
||||||
|
: "Delete this attachment."
|
||||||
|
}
|
||||||
|
impact="The file link will be removed from this record immediately."
|
||||||
|
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
|
||||||
|
confirmLabel="Delete file"
|
||||||
|
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
|
||||||
|
onClose={() => {
|
||||||
|
if (!deletingAttachmentId) {
|
||||||
|
setAttachmentPendingDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (attachmentPendingDelete) {
|
||||||
|
await handleDelete(attachmentPendingDelete);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
22
client/src/components/ProtectedRoute.tsx
Normal file
22
client/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PermissionKey } from "@mrp/shared";
|
||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../auth/AuthProvider";
|
||||||
|
|
||||||
|
export function ProtectedRoute({ requiredPermissions = [] }: { requiredPermissions?: PermissionKey[] }) {
|
||||||
|
const { isReady, token, user } = useAuth();
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return <div className="p-10 text-center text-muted">Loading workspace...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionSet = new Set(user.permissions);
|
||||||
|
const allowed = requiredPermissions.every((permission) => permissionSet.has(permission));
|
||||||
|
|
||||||
|
return allowed ? <Outlet /> : <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
15
client/src/components/ThemeToggle.tsx
Normal file
15
client/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useTheme } from "../theme/ThemeProvider";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { mode, toggleMode } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMode}
|
||||||
|
className="w-full rounded-xl border border-line/70 bg-page/70 px-3 py-2 text-sm font-semibold text-text transition hover:border-brand/60"
|
||||||
|
>
|
||||||
|
{mode === "light" ? "Dark mode" : "Light mode"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
client/src/index.css
Normal file
102
client/src/index.css
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--font-family: "Manrope";
|
||||||
|
--color-brand: 24 90 219;
|
||||||
|
--color-accent: 0 166 166;
|
||||||
|
--color-surface-brand: 244 247 251;
|
||||||
|
--color-surface: var(--color-surface-brand);
|
||||||
|
--color-page: 248 250 252;
|
||||||
|
--color-text: 15 23 42;
|
||||||
|
--color-muted: 90 106 133;
|
||||||
|
--color-line: 215 222 235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-brand: 63 140 255;
|
||||||
|
--color-accent: 34 211 238;
|
||||||
|
--color-surface: 30 41 59;
|
||||||
|
--color-page: 2 6 23;
|
||||||
|
--color-text: 226 232 240;
|
||||||
|
--color-muted: 148 163 184;
|
||||||
|
--color-line: 51 65 85;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(var(--color-brand) / 0.18), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgb(var(--color-accent) / 0.16), transparent 25%),
|
||||||
|
rgb(var(--color-page));
|
||||||
|
color: rgb(var(--color-text));
|
||||||
|
font-family: var(--font-family), sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not([type="color"]),
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
color: rgb(var(--color-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: rgb(var(--color-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-theme .wx-bar,
|
||||||
|
.gantt-theme .wx-task {
|
||||||
|
fill: rgb(var(--color-brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-theme {
|
||||||
|
--wx-font-family: var(--font-family), sans-serif;
|
||||||
|
--wx-background: rgb(var(--color-page));
|
||||||
|
--wx-background-alt: rgb(var(--color-surface));
|
||||||
|
--wx-color-font: rgb(var(--color-text));
|
||||||
|
--wx-color-secondary-font: rgb(var(--color-muted));
|
||||||
|
--wx-color-font-disabled: rgb(var(--color-muted));
|
||||||
|
--wx-color-link: rgb(var(--color-brand));
|
||||||
|
--wx-color-primary: rgb(var(--color-brand));
|
||||||
|
--wx-icon-color: rgb(var(--color-muted));
|
||||||
|
--wx-border: 1px solid rgb(var(--color-line));
|
||||||
|
--wx-box-shadow: 0 24px 60px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-theme .wx-layout,
|
||||||
|
.gantt-theme .wx-scale,
|
||||||
|
.gantt-theme .wx-gantt,
|
||||||
|
.gantt-theme .wx-table-container {
|
||||||
|
background-color: rgb(var(--color-page));
|
||||||
|
color: rgb(var(--color-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-theme .wx-grid,
|
||||||
|
.gantt-theme .wx-cell,
|
||||||
|
.gantt-theme .wx-row,
|
||||||
|
.gantt-theme .wx-text,
|
||||||
|
.gantt-theme .wx-task-name {
|
||||||
|
color: rgb(var(--color-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-theme .wx-cell,
|
||||||
|
.gantt-theme .wx-row {
|
||||||
|
border-color: rgb(var(--color-line));
|
||||||
|
}
|
||||||
896
client/src/lib/api.ts
Normal file
896
client/src/lib/api.ts
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
import type {
|
||||||
|
AdminDiagnosticsDto,
|
||||||
|
AdminAuthSessionDto,
|
||||||
|
BackupGuidanceDto,
|
||||||
|
AdminPermissionOptionDto,
|
||||||
|
AdminRoleDto,
|
||||||
|
AdminRoleInput,
|
||||||
|
SupportLogEntryDto,
|
||||||
|
SupportLogFiltersDto,
|
||||||
|
SupportLogListDto,
|
||||||
|
SupportSnapshotDto,
|
||||||
|
AdminUserDto,
|
||||||
|
AdminUserInput,
|
||||||
|
ApiResponse,
|
||||||
|
CompanyProfileDto,
|
||||||
|
CompanyProfileInput,
|
||||||
|
FileAttachmentDto,
|
||||||
|
PlanningTimelineDto,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
LogoutResponse,
|
||||||
|
} from "@mrp/shared";
|
||||||
|
import type {
|
||||||
|
CrmContactDto,
|
||||||
|
CrmContactInput,
|
||||||
|
CrmContactEntryDto,
|
||||||
|
CrmContactEntryInput,
|
||||||
|
CrmCustomerHierarchyOptionDto,
|
||||||
|
CrmRecordDetailDto,
|
||||||
|
CrmRecordInput,
|
||||||
|
CrmLifecycleStage,
|
||||||
|
CrmRecordStatus,
|
||||||
|
CrmRecordSummaryDto,
|
||||||
|
} from "@mrp/shared/dist/crm/types.js";
|
||||||
|
import type {
|
||||||
|
InventoryItemDetailDto,
|
||||||
|
InventoryItemInput,
|
||||||
|
InventoryItemOptionDto,
|
||||||
|
InventorySkuBuilderPreviewDto,
|
||||||
|
InventorySkuCatalogTreeDto,
|
||||||
|
InventorySkuFamilyDto,
|
||||||
|
InventorySkuFamilyInput,
|
||||||
|
InventorySkuNodeDto,
|
||||||
|
InventorySkuNodeInput,
|
||||||
|
InventoryReservationInput,
|
||||||
|
InventoryItemStatus,
|
||||||
|
InventoryItemSummaryDto,
|
||||||
|
InventoryTransferInput,
|
||||||
|
InventoryTransactionInput,
|
||||||
|
InventoryItemType,
|
||||||
|
WarehouseDetailDto,
|
||||||
|
WarehouseInput,
|
||||||
|
WarehouseLocationOptionDto,
|
||||||
|
WarehouseSummaryDto,
|
||||||
|
} from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import type {
|
||||||
|
ManufacturingStationDto,
|
||||||
|
ManufacturingStationInput,
|
||||||
|
ManufacturingItemOptionDto,
|
||||||
|
ManufacturingProjectOptionDto,
|
||||||
|
WorkOrderCompletionInput,
|
||||||
|
WorkOrderDetailDto,
|
||||||
|
WorkOrderInput,
|
||||||
|
WorkOrderMaterialIssueInput,
|
||||||
|
WorkOrderStatus,
|
||||||
|
WorkOrderSummaryDto,
|
||||||
|
} from "@mrp/shared";
|
||||||
|
import type {
|
||||||
|
ProjectCustomerOptionDto,
|
||||||
|
ProjectDetailDto,
|
||||||
|
ProjectDocumentOptionDto,
|
||||||
|
ProjectInput,
|
||||||
|
ProjectOwnerOptionDto,
|
||||||
|
ProjectPriority,
|
||||||
|
ProjectShipmentOptionDto,
|
||||||
|
ProjectStatus,
|
||||||
|
ProjectSummaryDto,
|
||||||
|
} from "@mrp/shared/dist/projects/types.js";
|
||||||
|
import type {
|
||||||
|
SalesCustomerOptionDto,
|
||||||
|
DemandPlanningRollupDto,
|
||||||
|
SalesDocumentDetailDto,
|
||||||
|
SalesDocumentInput,
|
||||||
|
SalesOrderPlanningDto,
|
||||||
|
SalesDocumentRevisionDto,
|
||||||
|
SalesDocumentStatus,
|
||||||
|
SalesDocumentSummaryDto,
|
||||||
|
} from "@mrp/shared/dist/sales/types.js";
|
||||||
|
import type {
|
||||||
|
PurchaseOrderDetailDto,
|
||||||
|
PurchaseOrderInput,
|
||||||
|
PurchaseOrderRevisionDto,
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
PurchaseOrderSummaryDto,
|
||||||
|
PurchaseVendorOptionDto,
|
||||||
|
} from "@mrp/shared";
|
||||||
|
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||||
|
import type {
|
||||||
|
ShipmentDetailDto,
|
||||||
|
ShipmentInput,
|
||||||
|
ShipmentOrderOptionDto,
|
||||||
|
ShipmentStatus,
|
||||||
|
ShipmentSummaryDto,
|
||||||
|
} from "@mrp/shared/dist/shipping/types.js";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, public readonly code: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(input: string, init?: RequestInit, token?: string): Promise<T> {
|
||||||
|
const response = await fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (await response.json()) as ApiResponse<T>;
|
||||||
|
if (!json.ok) {
|
||||||
|
throw new ApiError(json.error.message, json.error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(params: Record<string, string | undefined>) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value) {
|
||||||
|
searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `?${queryString}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login(payload: LoginRequest) {
|
||||||
|
return request<LoginResponse>("/api/v1/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
me(token: string) {
|
||||||
|
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
||||||
|
},
|
||||||
|
logout(token: string) {
|
||||||
|
return request<LogoutResponse>("/api/v1/auth/logout", { method: "POST" }, token);
|
||||||
|
},
|
||||||
|
getAdminDiagnostics(token: string) {
|
||||||
|
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
|
||||||
|
},
|
||||||
|
getBackupGuidance(token: string) {
|
||||||
|
return request<BackupGuidanceDto>("/api/v1/admin/backup-guidance", undefined, token);
|
||||||
|
},
|
||||||
|
getSupportSnapshot(token: string) {
|
||||||
|
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
|
||||||
|
},
|
||||||
|
getSupportSnapshotWithFilters(token: string, filters?: SupportLogFiltersDto) {
|
||||||
|
return request<SupportSnapshotDto>(
|
||||||
|
`/api/v1/admin/support-snapshot${buildQueryString({
|
||||||
|
level: filters?.level,
|
||||||
|
source: filters?.source,
|
||||||
|
query: filters?.query,
|
||||||
|
start: filters?.start,
|
||||||
|
end: filters?.end,
|
||||||
|
limit: filters?.limit?.toString(),
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSupportLogs(token: string, filters?: SupportLogFiltersDto) {
|
||||||
|
return request<SupportLogListDto>(
|
||||||
|
`/api/v1/admin/support-logs${buildQueryString({
|
||||||
|
level: filters?.level,
|
||||||
|
source: filters?.source,
|
||||||
|
query: filters?.query,
|
||||||
|
start: filters?.start,
|
||||||
|
end: filters?.end,
|
||||||
|
limit: filters?.limit?.toString(),
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getAdminPermissions(token: string) {
|
||||||
|
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
|
||||||
|
},
|
||||||
|
getAdminRoles(token: string) {
|
||||||
|
return request<AdminRoleDto[]>("/api/v1/admin/roles", undefined, token);
|
||||||
|
},
|
||||||
|
createAdminRole(token: string, payload: AdminRoleInput) {
|
||||||
|
return request<AdminRoleDto>("/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateAdminRole(token: string, roleId: string, payload: AdminRoleInput) {
|
||||||
|
return request<AdminRoleDto>(`/api/v1/admin/roles/${roleId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
getAdminUsers(token: string) {
|
||||||
|
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
|
||||||
|
},
|
||||||
|
getAdminSessions(token: string) {
|
||||||
|
return request<AdminAuthSessionDto[]>("/api/v1/admin/sessions", undefined, token);
|
||||||
|
},
|
||||||
|
revokeAdminSession(token: string, sessionId: string) {
|
||||||
|
return request<LogoutResponse>(`/api/v1/admin/sessions/${sessionId}/revoke`, { method: "POST" }, token);
|
||||||
|
},
|
||||||
|
createAdminUser(token: string, payload: AdminUserInput) {
|
||||||
|
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateAdminUser(token: string, userId: string, payload: AdminUserInput) {
|
||||||
|
return request<AdminUserDto>(`/api/v1/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
getCompanyProfile(token: string) {
|
||||||
|
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
|
||||||
|
},
|
||||||
|
updateCompanyProfile(token: string, payload: CompanyProfileInput) {
|
||||||
|
return request<CompanyProfileDto>(
|
||||||
|
"/api/v1/company-profile",
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async uploadFile(token: string, file: File, ownerType: string, ownerId: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("ownerType", ownerType);
|
||||||
|
formData.append("ownerId", ownerId);
|
||||||
|
|
||||||
|
const response = await fetch("/api/v1/files/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const json = (await response.json()) as ApiResponse<FileAttachmentDto>;
|
||||||
|
if (!json.ok) {
|
||||||
|
throw new ApiError(json.error.message, json.error.code);
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
|
},
|
||||||
|
async getFileContentBlob(token: string, fileId: string) {
|
||||||
|
const response = await fetch(`/api/v1/files/${fileId}/content`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to load file content.", "FILE_CONTENT_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
getAttachments(token: string, ownerType: string, ownerId: string) {
|
||||||
|
return request<FileAttachmentDto[]>(
|
||||||
|
`/api/v1/files${buildQueryString({
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
deleteAttachment(token: string, fileId: string) {
|
||||||
|
return request<FileAttachmentDto>(
|
||||||
|
`/api/v1/files/${fileId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getCustomers(
|
||||||
|
token: string,
|
||||||
|
filters?: {
|
||||||
|
q?: string;
|
||||||
|
status?: CrmRecordStatus;
|
||||||
|
lifecycleStage?: CrmLifecycleStage;
|
||||||
|
state?: string;
|
||||||
|
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return request<CrmRecordSummaryDto[]>(
|
||||||
|
`/api/v1/crm/customers${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
lifecycleStage: filters?.lifecycleStage,
|
||||||
|
state: filters?.state,
|
||||||
|
flag: filters?.flag,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getCustomer(token: string, customerId: string) {
|
||||||
|
return request<CrmRecordDetailDto>(`/api/v1/crm/customers/${customerId}`, undefined, token);
|
||||||
|
},
|
||||||
|
getCustomerHierarchyOptions(token: string, excludeCustomerId?: string) {
|
||||||
|
return request<CrmCustomerHierarchyOptionDto[]>(
|
||||||
|
`/api/v1/crm/customers/hierarchy-options${buildQueryString({
|
||||||
|
excludeCustomerId,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createCustomer(token: string, payload: CrmRecordInput) {
|
||||||
|
return request<CrmRecordDetailDto>(
|
||||||
|
"/api/v1/crm/customers",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateCustomer(token: string, customerId: string, payload: CrmRecordInput) {
|
||||||
|
return request<CrmRecordDetailDto>(
|
||||||
|
`/api/v1/crm/customers/${customerId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createCustomerContactEntry(token: string, customerId: string, payload: CrmContactEntryInput) {
|
||||||
|
return request<CrmContactEntryDto>(
|
||||||
|
`/api/v1/crm/customers/${customerId}/contact-history`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createCustomerContact(token: string, customerId: string, payload: CrmContactInput) {
|
||||||
|
return request<CrmContactDto>(
|
||||||
|
`/api/v1/crm/customers/${customerId}/contacts`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getVendors(
|
||||||
|
token: string,
|
||||||
|
filters?: {
|
||||||
|
q?: string;
|
||||||
|
status?: CrmRecordStatus;
|
||||||
|
lifecycleStage?: CrmLifecycleStage;
|
||||||
|
state?: string;
|
||||||
|
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return request<CrmRecordSummaryDto[]>(
|
||||||
|
`/api/v1/crm/vendors${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
lifecycleStage: filters?.lifecycleStage,
|
||||||
|
state: filters?.state,
|
||||||
|
flag: filters?.flag,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getVendor(token: string, vendorId: string) {
|
||||||
|
return request<CrmRecordDetailDto>(`/api/v1/crm/vendors/${vendorId}`, undefined, token);
|
||||||
|
},
|
||||||
|
createVendor(token: string, payload: CrmRecordInput) {
|
||||||
|
return request<CrmRecordDetailDto>(
|
||||||
|
"/api/v1/crm/vendors",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateVendor(token: string, vendorId: string, payload: CrmRecordInput) {
|
||||||
|
return request<CrmRecordDetailDto>(
|
||||||
|
`/api/v1/crm/vendors/${vendorId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createVendorContactEntry(token: string, vendorId: string, payload: CrmContactEntryInput) {
|
||||||
|
return request<CrmContactEntryDto>(
|
||||||
|
`/api/v1/crm/vendors/${vendorId}/contact-history`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createVendorContact(token: string, vendorId: string, payload: CrmContactInput) {
|
||||||
|
return request<CrmContactDto>(
|
||||||
|
`/api/v1/crm/vendors/${vendorId}/contacts`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getInventoryItems(token: string, filters?: { q?: string; status?: InventoryItemStatus; type?: InventoryItemType }) {
|
||||||
|
return request<InventoryItemSummaryDto[]>(
|
||||||
|
`/api/v1/inventory/items${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
type: filters?.type,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getInventoryItem(token: string, itemId: string) {
|
||||||
|
return request<InventoryItemDetailDto>(`/api/v1/inventory/items/${itemId}`, undefined, token);
|
||||||
|
},
|
||||||
|
getInventoryItemOptions(token: string) {
|
||||||
|
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
|
||||||
|
},
|
||||||
|
getInventorySkuFamilies(token: string) {
|
||||||
|
return request<InventorySkuFamilyDto[]>("/api/v1/inventory/sku/families", undefined, token);
|
||||||
|
},
|
||||||
|
getInventorySkuCatalog(token: string) {
|
||||||
|
return request<InventorySkuCatalogTreeDto>("/api/v1/inventory/sku/catalog", undefined, token);
|
||||||
|
},
|
||||||
|
getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) {
|
||||||
|
return request<InventorySkuNodeDto[]>(
|
||||||
|
`/api/v1/inventory/sku/nodes${buildQueryString({
|
||||||
|
familyId,
|
||||||
|
parentNodeId: parentNodeId ?? undefined,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) {
|
||||||
|
return request<InventorySkuBuilderPreviewDto>(
|
||||||
|
`/api/v1/inventory/sku/preview${buildQueryString({
|
||||||
|
familyId,
|
||||||
|
nodeId: nodeId ?? undefined,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) {
|
||||||
|
return request<InventorySkuFamilyDto>("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
createInventorySkuNode(token: string, payload: InventorySkuNodeInput) {
|
||||||
|
return request<InventorySkuNodeDto>("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
getWarehouseLocationOptions(token: string) {
|
||||||
|
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
|
||||||
|
},
|
||||||
|
createInventoryItem(token: string, payload: InventoryItemInput) {
|
||||||
|
return request<InventoryItemDetailDto>(
|
||||||
|
"/api/v1/inventory/items",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateInventoryItem(token: string, itemId: string, payload: InventoryItemInput) {
|
||||||
|
return request<InventoryItemDetailDto>(
|
||||||
|
`/api/v1/inventory/items/${itemId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createInventoryTransaction(token: string, itemId: string, payload: InventoryTransactionInput) {
|
||||||
|
return request<InventoryItemDetailDto>(
|
||||||
|
`/api/v1/inventory/items/${itemId}/transactions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createInventoryTransfer(token: string, itemId: string, payload: InventoryTransferInput) {
|
||||||
|
return request<InventoryItemDetailDto>(
|
||||||
|
`/api/v1/inventory/items/${itemId}/transfers`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createInventoryReservation(token: string, itemId: string, payload: InventoryReservationInput) {
|
||||||
|
return request<InventoryItemDetailDto>(
|
||||||
|
`/api/v1/inventory/items/${itemId}/reservations`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getWarehouses(token: string) {
|
||||||
|
return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token);
|
||||||
|
},
|
||||||
|
getWarehouse(token: string, warehouseId: string) {
|
||||||
|
return request<WarehouseDetailDto>(`/api/v1/inventory/warehouses/${warehouseId}`, undefined, token);
|
||||||
|
},
|
||||||
|
createWarehouse(token: string, payload: WarehouseInput) {
|
||||||
|
return request<WarehouseDetailDto>(
|
||||||
|
"/api/v1/inventory/warehouses",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateWarehouse(token: string, warehouseId: string, payload: WarehouseInput) {
|
||||||
|
return request<WarehouseDetailDto>(
|
||||||
|
`/api/v1/inventory/warehouses/${warehouseId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getProjects(
|
||||||
|
token: string,
|
||||||
|
filters?: { q?: string; status?: ProjectStatus; priority?: ProjectPriority; customerId?: string; ownerId?: string }
|
||||||
|
) {
|
||||||
|
return request<ProjectSummaryDto[]>(
|
||||||
|
`/api/v1/projects${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
priority: filters?.priority,
|
||||||
|
customerId: filters?.customerId,
|
||||||
|
ownerId: filters?.ownerId,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getProject(token: string, projectId: string) {
|
||||||
|
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, undefined, token);
|
||||||
|
},
|
||||||
|
createProject(token: string, payload: ProjectInput) {
|
||||||
|
return request<ProjectDetailDto>("/api/v1/projects", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateProject(token: string, projectId: string, payload: ProjectInput) {
|
||||||
|
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
getProjectCustomerOptions(token: string) {
|
||||||
|
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
|
||||||
|
},
|
||||||
|
getProjectOwnerOptions(token: string) {
|
||||||
|
return request<ProjectOwnerOptionDto[]>("/api/v1/projects/owners/options", undefined, token);
|
||||||
|
},
|
||||||
|
getProjectQuoteOptions(token: string, customerId?: string) {
|
||||||
|
return request<ProjectDocumentOptionDto[]>(
|
||||||
|
`/api/v1/projects/quotes/options${buildQueryString({ customerId })}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getProjectOrderOptions(token: string, customerId?: string) {
|
||||||
|
return request<ProjectDocumentOptionDto[]>(
|
||||||
|
`/api/v1/projects/orders/options${buildQueryString({ customerId })}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getProjectShipmentOptions(token: string, customerId?: string) {
|
||||||
|
return request<ProjectShipmentOptionDto[]>(
|
||||||
|
`/api/v1/projects/shipments/options${buildQueryString({ customerId })}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getManufacturingItemOptions(token: string) {
|
||||||
|
return request<ManufacturingItemOptionDto[]>("/api/v1/manufacturing/items/options", undefined, token);
|
||||||
|
},
|
||||||
|
getManufacturingProjectOptions(token: string) {
|
||||||
|
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
|
||||||
|
},
|
||||||
|
getManufacturingStations(token: string) {
|
||||||
|
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
|
||||||
|
},
|
||||||
|
createManufacturingStation(token: string, payload: ManufacturingStationInput) {
|
||||||
|
return request<ManufacturingStationDto>("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
|
||||||
|
return request<WorkOrderSummaryDto[]>(
|
||||||
|
`/api/v1/manufacturing/work-orders${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
projectId: filters?.projectId,
|
||||||
|
itemId: filters?.itemId,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getWorkOrder(token: string, workOrderId: string) {
|
||||||
|
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, undefined, token);
|
||||||
|
},
|
||||||
|
createWorkOrder(token: string, payload: WorkOrderInput) {
|
||||||
|
return request<WorkOrderDetailDto>("/api/v1/manufacturing/work-orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
|
||||||
|
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateWorkOrderStatus(token: string, workOrderId: string, status: WorkOrderStatus) {
|
||||||
|
return request<WorkOrderDetailDto>(
|
||||||
|
`/api/v1/manufacturing/work-orders/${workOrderId}/status`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
|
||||||
|
return request<WorkOrderDetailDto>(
|
||||||
|
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
recordWorkOrderCompletion(token: string, workOrderId: string, payload: WorkOrderCompletionInput) {
|
||||||
|
return request<WorkOrderDetailDto>(
|
||||||
|
`/api/v1/manufacturing/work-orders/${workOrderId}/completions`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getPlanningTimeline(token: string) {
|
||||||
|
return request<PlanningTimelineDto>("/api/v1/gantt/timeline", undefined, token);
|
||||||
|
},
|
||||||
|
getSalesCustomers(token: string) {
|
||||||
|
return request<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);
|
||||||
|
},
|
||||||
|
getPurchaseVendors(token: string) {
|
||||||
|
return request<PurchaseVendorOptionDto[]>("/api/v1/purchasing/vendors/options", undefined, token);
|
||||||
|
},
|
||||||
|
getQuotes(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
|
||||||
|
return request<SalesDocumentSummaryDto[]>(
|
||||||
|
`/api/v1/sales/quotes${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getQuote(token: string, quoteId: string) {
|
||||||
|
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, undefined, token);
|
||||||
|
},
|
||||||
|
createQuote(token: string, payload: SalesDocumentInput) {
|
||||||
|
return request<SalesDocumentDetailDto>("/api/v1/sales/quotes", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateQuote(token: string, quoteId: string, payload: SalesDocumentInput) {
|
||||||
|
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateQuoteStatus(token: string, quoteId: string, status: SalesDocumentStatus) {
|
||||||
|
return request<SalesDocumentDetailDto>(
|
||||||
|
`/api/v1/sales/quotes/${quoteId}/status`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
approveQuote(token: string, quoteId: string) {
|
||||||
|
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/approve`, { method: "POST" }, token);
|
||||||
|
},
|
||||||
|
getQuoteRevisions(token: string, quoteId: string) {
|
||||||
|
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/quotes/${quoteId}/revisions`, undefined, token);
|
||||||
|
},
|
||||||
|
convertQuoteToSalesOrder(token: string, quoteId: string) {
|
||||||
|
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/convert`, { method: "POST" }, token);
|
||||||
|
},
|
||||||
|
getSalesOrders(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
|
||||||
|
return request<SalesDocumentSummaryDto[]>(
|
||||||
|
`/api/v1/sales/orders${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSalesOrder(token: string, orderId: string) {
|
||||||
|
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token);
|
||||||
|
},
|
||||||
|
getSalesOrderPlanning(token: string, orderId: string) {
|
||||||
|
return request<SalesOrderPlanningDto>(`/api/v1/sales/orders/${orderId}/planning`, undefined, token);
|
||||||
|
},
|
||||||
|
getDemandPlanningRollup(token: string) {
|
||||||
|
return request<DemandPlanningRollupDto>("/api/v1/sales/planning-rollup", undefined, token);
|
||||||
|
},
|
||||||
|
createSalesOrder(token: string, payload: SalesDocumentInput) {
|
||||||
|
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateSalesOrder(token: string, orderId: string, payload: SalesDocumentInput) {
|
||||||
|
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateSalesOrderStatus(token: string, orderId: string, status: SalesDocumentStatus) {
|
||||||
|
return request<SalesDocumentDetailDto>(
|
||||||
|
`/api/v1/sales/orders/${orderId}/status`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
approveSalesOrder(token: string, orderId: string) {
|
||||||
|
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}/approve`, { method: "POST" }, token);
|
||||||
|
},
|
||||||
|
getSalesOrderRevisions(token: string, orderId: string) {
|
||||||
|
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/orders/${orderId}/revisions`, undefined, token);
|
||||||
|
},
|
||||||
|
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) {
|
||||||
|
return request<PurchaseOrderSummaryDto[]>(
|
||||||
|
`/api/v1/purchasing/orders${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
vendorId: filters?.vendorId,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getPurchaseOrder(token: string, orderId: string) {
|
||||||
|
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, undefined, token);
|
||||||
|
},
|
||||||
|
getPurchaseOrderRevisions(token: string, orderId: string) {
|
||||||
|
return request<PurchaseOrderRevisionDto[]>(`/api/v1/purchasing/orders/${orderId}/revisions`, undefined, token);
|
||||||
|
},
|
||||||
|
createPurchaseOrder(token: string, payload: PurchaseOrderInput) {
|
||||||
|
return request<PurchaseOrderDetailDto>("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updatePurchaseOrder(token: string, orderId: string, payload: PurchaseOrderInput) {
|
||||||
|
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updatePurchaseOrderStatus(token: string, orderId: string, status: PurchaseOrderStatus) {
|
||||||
|
return request<PurchaseOrderDetailDto>(
|
||||||
|
`/api/v1/purchasing/orders/${orderId}/status`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createPurchaseReceipt(token: string, orderId: string, payload: PurchaseReceiptInput) {
|
||||||
|
return request<PurchaseOrderDetailDto>(
|
||||||
|
`/api/v1/purchasing/orders/${orderId}/receipts`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getShipmentOrderOptions(token: string) {
|
||||||
|
return request<ShipmentOrderOptionDto[]>("/api/v1/shipping/orders/options", undefined, token);
|
||||||
|
},
|
||||||
|
getShipments(token: string, filters?: { q?: string; status?: ShipmentStatus; salesOrderId?: string }) {
|
||||||
|
return request<ShipmentSummaryDto[]>(
|
||||||
|
`/api/v1/shipping/shipments${buildQueryString({
|
||||||
|
q: filters?.q,
|
||||||
|
status: filters?.status,
|
||||||
|
salesOrderId: filters?.salesOrderId,
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getShipment(token: string, shipmentId: string) {
|
||||||
|
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, undefined, token);
|
||||||
|
},
|
||||||
|
createShipment(token: string, payload: ShipmentInput) {
|
||||||
|
return request<ShipmentDetailDto>("/api/v1/shipping/shipments", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateShipment(token: string, shipmentId: string, payload: ShipmentInput) {
|
||||||
|
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
|
},
|
||||||
|
updateShipmentStatus(token: string, shipmentId: string, status: ShipmentStatus) {
|
||||||
|
return request<ShipmentDetailDto>(
|
||||||
|
`/api/v1/shipping/shipments/${shipmentId}/status`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async getShipmentPackingSlipPdf(token: string, shipmentId: string) {
|
||||||
|
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/packing-slip.pdf`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to render packing slip PDF.", "PACKING_SLIP_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
async getShipmentLabelPdf(token: string, shipmentId: string) {
|
||||||
|
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/shipping-label.pdf`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to render shipping label PDF.", "SHIPPING_LABEL_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
async getShipmentBillOfLadingPdf(token: string, shipmentId: string) {
|
||||||
|
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/bill-of-lading.pdf`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to render bill of lading PDF.", "BILL_OF_LADING_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
async getQuotePdf(token: string, quoteId: string) {
|
||||||
|
const response = await fetch(`/api/v1/documents/sales/quotes/${quoteId}/document.pdf`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to render quote PDF.", "QUOTE_PDF_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
async getSalesOrderPdf(token: string, orderId: string) {
|
||||||
|
const response = await fetch(`/api/v1/documents/sales/orders/${orderId}/document.pdf`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to render sales order PDF.", "SALES_ORDER_PDF_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
async getPurchaseOrderPdf(token: string, orderId: string) {
|
||||||
|
const response = await fetch(`/api/v1/documents/purchasing/orders/${orderId}/document.pdf`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to render purchase order PDF.", "PURCHASE_ORDER_PDF_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
async getCompanyProfilePreviewPdf(token: string) {
|
||||||
|
const response = await fetch("/api/v1/documents/company-profile-preview.pdf", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError("Unable to render company profile preview PDF.", "PDF_PREVIEW_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
};
|
||||||
273
client/src/main.tsx
Normal file
273
client/src/main.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
|
||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { AppShell } from "./components/AppShell";
|
||||||
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
|
import { DashboardPage } from "./modules/dashboard/DashboardPage";
|
||||||
|
import { LoginPage } from "./modules/login/LoginPage";
|
||||||
|
import { ThemeProvider } from "./theme/ThemeProvider";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const CompanySettingsPage = React.lazy(() =>
|
||||||
|
import("./modules/settings/CompanySettingsPage").then((module) => ({ default: module.CompanySettingsPage }))
|
||||||
|
);
|
||||||
|
const AdminDiagnosticsPage = React.lazy(() =>
|
||||||
|
import("./modules/settings/AdminDiagnosticsPage").then((module) => ({ default: module.AdminDiagnosticsPage }))
|
||||||
|
);
|
||||||
|
const UserManagementPage = React.lazy(() =>
|
||||||
|
import("./modules/settings/UserManagementPage").then((module) => ({ default: module.UserManagementPage }))
|
||||||
|
);
|
||||||
|
const CustomersPage = React.lazy(() =>
|
||||||
|
import("./modules/crm/CustomersPage").then((module) => ({ default: module.CustomersPage }))
|
||||||
|
);
|
||||||
|
const VendorsPage = React.lazy(() =>
|
||||||
|
import("./modules/crm/VendorsPage").then((module) => ({ default: module.VendorsPage }))
|
||||||
|
);
|
||||||
|
const CrmDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/crm/CrmDetailPage").then((module) => ({ default: module.CrmDetailPage }))
|
||||||
|
);
|
||||||
|
const CrmFormPage = React.lazy(() =>
|
||||||
|
import("./modules/crm/CrmFormPage").then((module) => ({ default: module.CrmFormPage }))
|
||||||
|
);
|
||||||
|
const InventoryItemsPage = React.lazy(() =>
|
||||||
|
import("./modules/inventory/InventoryItemsPage").then((module) => ({ default: module.InventoryItemsPage }))
|
||||||
|
);
|
||||||
|
const InventoryDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/inventory/InventoryDetailPage").then((module) => ({ default: module.InventoryDetailPage }))
|
||||||
|
);
|
||||||
|
const InventoryFormPage = React.lazy(() =>
|
||||||
|
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
|
||||||
|
);
|
||||||
|
const InventorySkuMasterPage = React.lazy(() =>
|
||||||
|
import("./modules/inventory/InventorySkuMasterPage").then((module) => ({ default: module.InventorySkuMasterPage }))
|
||||||
|
);
|
||||||
|
const WarehousesPage = React.lazy(() =>
|
||||||
|
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
|
||||||
|
);
|
||||||
|
const WarehouseDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/inventory/WarehouseDetailPage").then((module) => ({ default: module.WarehouseDetailPage }))
|
||||||
|
);
|
||||||
|
const WarehouseFormPage = React.lazy(() =>
|
||||||
|
import("./modules/inventory/WarehouseFormPage").then((module) => ({ default: module.WarehouseFormPage }))
|
||||||
|
);
|
||||||
|
const ProjectsPage = React.lazy(() =>
|
||||||
|
import("./modules/projects/ProjectsPage").then((module) => ({ default: module.ProjectsPage }))
|
||||||
|
);
|
||||||
|
const ProjectDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/projects/ProjectDetailPage").then((module) => ({ default: module.ProjectDetailPage }))
|
||||||
|
);
|
||||||
|
const ProjectFormPage = React.lazy(() =>
|
||||||
|
import("./modules/projects/ProjectFormPage").then((module) => ({ default: module.ProjectFormPage }))
|
||||||
|
);
|
||||||
|
const ManufacturingPage = React.lazy(() =>
|
||||||
|
import("./modules/manufacturing/ManufacturingPage").then((module) => ({ default: module.ManufacturingPage }))
|
||||||
|
);
|
||||||
|
const WorkOrderDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/manufacturing/WorkOrderDetailPage").then((module) => ({ default: module.WorkOrderDetailPage }))
|
||||||
|
);
|
||||||
|
const WorkOrderFormPage = React.lazy(() =>
|
||||||
|
import("./modules/manufacturing/WorkOrderFormPage").then((module) => ({ default: module.WorkOrderFormPage }))
|
||||||
|
);
|
||||||
|
const PurchaseListPage = React.lazy(() =>
|
||||||
|
import("./modules/purchasing/PurchaseListPage").then((module) => ({ default: module.PurchaseListPage }))
|
||||||
|
);
|
||||||
|
const PurchaseDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/purchasing/PurchaseDetailPage").then((module) => ({ default: module.PurchaseDetailPage }))
|
||||||
|
);
|
||||||
|
const PurchaseFormPage = React.lazy(() =>
|
||||||
|
import("./modules/purchasing/PurchaseFormPage").then((module) => ({ default: module.PurchaseFormPage }))
|
||||||
|
);
|
||||||
|
const SalesListPage = React.lazy(() =>
|
||||||
|
import("./modules/sales/SalesListPage").then((module) => ({ default: module.SalesListPage }))
|
||||||
|
);
|
||||||
|
const SalesDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/sales/SalesDetailPage").then((module) => ({ default: module.SalesDetailPage }))
|
||||||
|
);
|
||||||
|
const SalesFormPage = React.lazy(() =>
|
||||||
|
import("./modules/sales/SalesFormPage").then((module) => ({ default: module.SalesFormPage }))
|
||||||
|
);
|
||||||
|
const ShipmentListPage = React.lazy(() =>
|
||||||
|
import("./modules/shipping/ShipmentListPage").then((module) => ({ default: module.ShipmentListPage }))
|
||||||
|
);
|
||||||
|
const ShipmentDetailPage = React.lazy(() =>
|
||||||
|
import("./modules/shipping/ShipmentDetailPage").then((module) => ({ default: module.ShipmentDetailPage }))
|
||||||
|
);
|
||||||
|
const ShipmentFormPage = React.lazy(() =>
|
||||||
|
import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage }))
|
||||||
|
);
|
||||||
|
const GanttPage = React.lazy(() =>
|
||||||
|
import("./modules/gantt/GanttPage").then((module) => ({ default: module.GanttPage }))
|
||||||
|
);
|
||||||
|
|
||||||
|
function RouteFallback() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
|
||||||
|
Loading module...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lazyElement(element: React.ReactNode) {
|
||||||
|
return <React.Suspense fallback={<RouteFallback />}>{element}</React.Suspense>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{ path: "/login", element: <LoginPage /> },
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: <AppShell />,
|
||||||
|
children: [
|
||||||
|
{ path: "/", element: <DashboardPage /> },
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
|
||||||
|
children: [{ path: "/settings/company", element: lazyElement(<CompanySettingsPage />) }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/settings/admin-diagnostics", element: lazyElement(<AdminDiagnosticsPage />) },
|
||||||
|
{ path: "/settings/users", element: lazyElement(<UserManagementPage />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/crm/customers", element: lazyElement(<CustomersPage />) },
|
||||||
|
{ path: "/crm/customers/:customerId", element: lazyElement(<CrmDetailPage entity="customer" />) },
|
||||||
|
{ path: "/crm/vendors", element: lazyElement(<VendorsPage />) },
|
||||||
|
{ path: "/crm/vendors/:vendorId", element: lazyElement(<CrmDetailPage entity="vendor" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.inventoryRead]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
|
||||||
|
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
|
||||||
|
{ path: "/inventory/sku-master", element: lazyElement(<InventorySkuMasterPage />) },
|
||||||
|
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
|
||||||
|
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/projects", element: lazyElement(<ProjectsPage />) },
|
||||||
|
{ path: "/projects/:projectId", element: lazyElement(<ProjectDetailPage />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/manufacturing/work-orders", element: lazyElement(<ManufacturingPage />) },
|
||||||
|
{ path: "/manufacturing/work-orders/:workOrderId", element: lazyElement(<WorkOrderDetailPage />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/purchasing/orders", element: lazyElement(<PurchaseListPage />) },
|
||||||
|
{ path: "/purchasing/orders/:orderId", element: lazyElement(<PurchaseDetailPage />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/sales/quotes", element: lazyElement(<SalesListPage entity="quote" />) },
|
||||||
|
{ path: "/sales/quotes/:quoteId", element: lazyElement(<SalesDetailPage entity="quote" />) },
|
||||||
|
{ path: "/sales/orders", element: lazyElement(<SalesListPage entity="order" />) },
|
||||||
|
{ path: "/sales/orders/:orderId", element: lazyElement(<SalesDetailPage entity="order" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.shippingRead]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/shipping/shipments", element: lazyElement(<ShipmentListPage />) },
|
||||||
|
{ path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/crm/customers/new", element: lazyElement(<CrmFormPage entity="customer" mode="create" />) },
|
||||||
|
{ path: "/crm/customers/:customerId/edit", element: lazyElement(<CrmFormPage entity="customer" mode="edit" />) },
|
||||||
|
{ path: "/crm/vendors/new", element: lazyElement(<CrmFormPage entity="vendor" mode="create" />) },
|
||||||
|
{ path: "/crm/vendors/:vendorId/edit", element: lazyElement(<CrmFormPage entity="vendor" mode="edit" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/projects/new", element: lazyElement(<ProjectFormPage mode="create" />) },
|
||||||
|
{ path: "/projects/:projectId/edit", element: lazyElement(<ProjectFormPage mode="edit" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingWrite]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/manufacturing/work-orders/new", element: lazyElement(<WorkOrderFormPage mode="create" />) },
|
||||||
|
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: lazyElement(<WorkOrderFormPage mode="edit" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/purchasing/orders/new", element: lazyElement(<PurchaseFormPage mode="create" />) },
|
||||||
|
{ path: "/purchasing/orders/:orderId/edit", element: lazyElement(<PurchaseFormPage mode="edit" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/sales/quotes/new", element: lazyElement(<SalesFormPage entity="quote" mode="create" />) },
|
||||||
|
{ path: "/sales/quotes/:quoteId/edit", element: lazyElement(<SalesFormPage entity="quote" mode="edit" />) },
|
||||||
|
{ path: "/sales/orders/new", element: lazyElement(<SalesFormPage entity="order" mode="create" />) },
|
||||||
|
{ path: "/sales/orders/:orderId/edit", element: lazyElement(<SalesFormPage entity="order" mode="edit" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.shippingWrite]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/shipping/shipments/new", element: lazyElement(<ShipmentFormPage mode="create" />) },
|
||||||
|
{ path: "/shipping/shipments/:shipmentId/edit", element: lazyElement(<ShipmentFormPage mode="edit" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
|
||||||
|
children: [
|
||||||
|
{ path: "/inventory/items/new", element: lazyElement(<InventoryFormPage mode="create" />) },
|
||||||
|
{ path: "/inventory/items/:itemId/edit", element: lazyElement(<InventoryFormPage mode="edit" />) },
|
||||||
|
{ path: "/inventory/warehouses/new", element: lazyElement(<WarehouseFormPage mode="create" />) },
|
||||||
|
{ path: "/inventory/warehouses/:warehouseId/edit", element: lazyElement(<WarehouseFormPage mode="edit" />) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
|
||||||
|
children: [{ path: "/planning/gantt", element: lazyElement(<GanttPage />) }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "*", element: <Navigate to="/" replace /> },
|
||||||
|
]);
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
21
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal file
21
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
|
|
||||||
|
interface CrmAttachmentsPanelProps {
|
||||||
|
ownerType: string;
|
||||||
|
ownerId: string;
|
||||||
|
onAttachmentCountChange?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrmAttachmentsPanel({ ownerType, ownerId, onAttachmentCountChange }: CrmAttachmentsPanelProps) {
|
||||||
|
return (
|
||||||
|
<FileAttachmentsPanel
|
||||||
|
ownerType={ownerType}
|
||||||
|
ownerId={ownerId}
|
||||||
|
eyebrow="Attachments"
|
||||||
|
title="Shared files"
|
||||||
|
description="Drawings, customer markups, vendor documents, and other reference files linked to this record."
|
||||||
|
emptyMessage="No attachments have been added to this record yet."
|
||||||
|
onAttachmentCountChange={onAttachmentCountChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
client/src/modules/crm/CrmContactEntryForm.tsx
Normal file
72
client/src/modules/crm/CrmContactEntryForm.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { CrmContactEntryInput } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
|
||||||
|
import { crmContactTypeOptions } from "./config";
|
||||||
|
|
||||||
|
interface CrmContactEntryFormProps {
|
||||||
|
form: CrmContactEntryInput;
|
||||||
|
isSaving: boolean;
|
||||||
|
status: string;
|
||||||
|
onChange: <Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrmContactEntryForm({ form, isSaving, status, onChange, onSubmit }: CrmContactEntryFormProps) {
|
||||||
|
return (
|
||||||
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(220px,1.1fr)]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Interaction type</span>
|
||||||
|
<select
|
||||||
|
value={form.type}
|
||||||
|
onChange={(event) => onChange("type", event.target.value as CrmContactEntryInput["type"])}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{crmContactTypeOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Contact timestamp</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.contactAt.slice(0, 16)}
|
||||||
|
onChange={(event) => onChange("contactAt", new Date(event.target.value).toISOString())}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Summary</span>
|
||||||
|
<input
|
||||||
|
value={form.summary}
|
||||||
|
onChange={(event) => onChange("summary", event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
placeholder="Short headline for the interaction"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Details</span>
|
||||||
|
<textarea
|
||||||
|
value={form.body}
|
||||||
|
onChange={(event) => onChange("body", event.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
placeholder="Capture what happened, follow-ups, and commitments."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Add entry"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
client/src/modules/crm/CrmContactTypeBadge.tsx
Normal file
13
client/src/modules/crm/CrmContactTypeBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { CrmContactEntryType } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
|
||||||
|
import { crmContactTypeOptions, crmContactTypePalette } from "./config";
|
||||||
|
|
||||||
|
export function CrmContactTypeBadge({ type }: { type: CrmContactEntryType }) {
|
||||||
|
const label = crmContactTypeOptions.find((option) => option.value === type)?.label ?? type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${crmContactTypePalette[type]}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
154
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { CrmContactDto, CrmContactInput } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { crmContactRoleOptions, emptyCrmContactInput, type CrmEntity } from "./config";
|
||||||
|
|
||||||
|
interface CrmContactsPanelProps {
|
||||||
|
entity: CrmEntity;
|
||||||
|
ownerId: string;
|
||||||
|
contacts: CrmContactDto[];
|
||||||
|
onContactsChange: (contacts: CrmContactDto[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }: CrmContactsPanelProps) {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [form, setForm] = useState<CrmContactInput>(emptyCrmContactInput);
|
||||||
|
const [status, setStatus] = useState("Add account contacts for purchasing, AP, shipping, and engineering.");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
|
||||||
|
|
||||||
|
function updateField<Key extends keyof CrmContactInput>(key: Key, value: CrmContactInput[Key]) {
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Saving contact...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextContact =
|
||||||
|
entity === "customer"
|
||||||
|
? await api.createCustomerContact(token, ownerId, form)
|
||||||
|
: await api.createVendorContact(token, ownerId, form);
|
||||||
|
|
||||||
|
onContactsChange(
|
||||||
|
[nextContact, ...contacts]
|
||||||
|
.sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary) || left.fullName.localeCompare(right.fullName))
|
||||||
|
);
|
||||||
|
setForm({
|
||||||
|
...emptyCrmContactInput,
|
||||||
|
isPrimary: false,
|
||||||
|
});
|
||||||
|
setStatus("Contact added.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save CRM contact.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">People on this account</h4>
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{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">
|
||||||
|
No contacts have been added yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
contacts.map((contact) => (
|
||||||
|
<div key={contact.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text">
|
||||||
|
{contact.fullName} {contact.isPrimary ? <span className="text-brand">• Primary</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted lg:text-right">
|
||||||
|
<div>{contact.email}</div>
|
||||||
|
<div>{contact.phone}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Full name</span>
|
||||||
|
<input
|
||||||
|
value={form.fullName}
|
||||||
|
onChange={(event) => updateField("fullName", event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Role</span>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{crmContactRoleOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => updateField("email", event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Phone</span>
|
||||||
|
<input
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(event) => updateField("phone", event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isPrimary}
|
||||||
|
onChange={(event) => updateField("isPrimary", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-text">Primary contact</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="text-sm text-muted">{status}</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Add contact"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
392
client/src/modules/crm/CrmDetailPage.tsx
Normal file
392
client/src/modules/crm/CrmDetailPage.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
import type { PurchaseOrderSummaryDto } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { CrmAttachmentsPanel } from "./CrmAttachmentsPanel";
|
||||||
|
import { CrmContactsPanel } from "./CrmContactsPanel";
|
||||||
|
import { CrmContactEntryForm } from "./CrmContactEntryForm";
|
||||||
|
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
|
||||||
|
import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
|
||||||
|
import { CrmStatusBadge } from "./CrmStatusBadge";
|
||||||
|
import { type CrmEntity, crmConfigs, emptyCrmContactEntryInput } from "./config";
|
||||||
|
|
||||||
|
interface CrmDetailPageProps {
|
||||||
|
entity: CrmEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { customerId, vendorId } = useParams();
|
||||||
|
const recordId = entity === "customer" ? customerId : vendorId;
|
||||||
|
const config = crmConfigs[entity];
|
||||||
|
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
|
||||||
|
const [relatedPurchaseOrders, setRelatedPurchaseOrders] = useState<PurchaseOrderSummaryDto[]>([]);
|
||||||
|
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
|
||||||
|
const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput);
|
||||||
|
const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account.");
|
||||||
|
const [isSavingContactEntry, setIsSavingContactEntry] = useState(false);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
|
||||||
|
|
||||||
|
loadRecord
|
||||||
|
.then((nextRecord) => {
|
||||||
|
setRecord(nextRecord);
|
||||||
|
setStatus(`${config.singularLabel} record loaded.`);
|
||||||
|
setContactEntryStatus("Add a timeline entry for this account.");
|
||||||
|
if (entity === "vendor") {
|
||||||
|
return api.getPurchaseOrders(token, { vendorId: nextRecord.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.then((purchaseOrders) => setRelatedPurchaseOrders(purchaseOrders))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [config.singularLabel, entity, recordId, token]);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContactEntryField<Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) {
|
||||||
|
setContactEntryForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContactEntrySubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token || !recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingContactEntry(true);
|
||||||
|
setContactEntryStatus("Saving timeline entry...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextEntry =
|
||||||
|
entity === "customer"
|
||||||
|
? await api.createCustomerContactEntry(token, recordId, contactEntryForm)
|
||||||
|
: await api.createVendorContactEntry(token, recordId, contactEntryForm);
|
||||||
|
|
||||||
|
setRecord((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
contactHistory: [nextEntry, ...current.contactHistory].sort(
|
||||||
|
(left, right) => new Date(right.contactAt).getTime() - new Date(left.contactAt).getTime()
|
||||||
|
),
|
||||||
|
rollups: {
|
||||||
|
lastContactAt: nextEntry.contactAt,
|
||||||
|
contactHistoryCount: (current.rollups?.contactHistoryCount ?? current.contactHistory.length) + 1,
|
||||||
|
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
|
||||||
|
attachmentCount: current.rollups?.attachmentCount ?? 0,
|
||||||
|
childCustomerCount: current.rollups?.childCustomerCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
setContactEntryForm({
|
||||||
|
...emptyCrmContactEntryInput,
|
||||||
|
contactAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setContactEntryStatus("Timeline entry added.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save timeline entry.";
|
||||||
|
setContactEntryStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSavingContactEntry(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-bold text-text">{record.name}</h3>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<CrmStatusBadge status={record.status} />
|
||||||
|
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
to={config.routeBase}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||||
|
>
|
||||||
|
Back to {config.collectionLabel.toLowerCase()}
|
||||||
|
</Link>
|
||||||
|
{canManage ? (
|
||||||
|
<Link
|
||||||
|
to={`${config.routeBase}/${record.id}/edit`}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Edit {config.singularLabel.toLowerCase()}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||||
|
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact</p>
|
||||||
|
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
|
||||||
|
<dd className="mt-1 text-sm text-text">{record.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt>
|
||||||
|
<dd className="mt-1 text-sm text-text">{record.phone}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Address</dt>
|
||||||
|
<dd className="mt-1 whitespace-pre-line text-sm text-text">
|
||||||
|
{[record.addressLine1, record.addressLine2, `${record.city}, ${record.state} ${record.postalCode}`, record.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial terms</dt>
|
||||||
|
<dd className="mt-2 grid gap-3 text-sm text-text md:grid-cols-2">
|
||||||
|
<div>Payment terms: {record.paymentTerms ?? "Not set"}</div>
|
||||||
|
<div>Currency: {record.currencyCode ?? "USD"}</div>
|
||||||
|
<div>Tax exempt: {record.taxExempt ? "Yes" : "No"}</div>
|
||||||
|
<div>Credit hold: {record.creditHold ? "Yes" : "No"}</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Internal Notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">
|
||||||
|
{record.notes || "No internal notes recorded for this account yet."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||||
|
Created {new Date(record.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operational Flags</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.12em]">
|
||||||
|
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Preferred</span> : null}
|
||||||
|
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Strategic</span> : null}
|
||||||
|
{record.requiresApproval ? <span className="rounded-full border border-amber-400/40 px-3 py-1 text-amber-700 dark:text-amber-300">Requires Approval</span> : null}
|
||||||
|
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-3 py-1 text-rose-700 dark:text-rose-300">Blocked</span> : null}
|
||||||
|
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
|
||||||
|
<span className="rounded-full border border-line/70 px-3 py-1 text-muted">Standard</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{entity === "customer" ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reseller Profile</p>
|
||||||
|
<div className="mt-3 grid gap-3 text-sm text-text">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Account type:</span>{" "}
|
||||||
|
{record.isReseller ? "Reseller" : record.parentCustomerName ? "End customer" : "Direct customer"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Discount:</span> {(record.resellerDiscountPercent ?? 0).toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Parent reseller:</span> {record.parentCustomerName ?? "None"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Child accounts:</span> {record.childCustomers?.length ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<section className="grid gap-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">Last Contact</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">
|
||||||
|
{record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[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">Timeline Entries</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.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">Account Contacts</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[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">Attachments</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">End customers under this reseller</h4>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{record.childCustomers?.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.id}
|
||||||
|
to={`/crm/customers/${child.id}`}
|
||||||
|
className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 transition hover:border-brand/50 hover:bg-page/80"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold text-text">{child.name}</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CrmStatusBadge status={child.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{entity === "vendor" ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Activity</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`/purchasing/orders/new?vendorId=${record.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
New purchase order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Open purchasing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{relatedPurchaseOrders.length === 0 ? (
|
||||||
|
<div className="mt-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-6 space-y-3">
|
||||||
|
{relatedPurchaseOrders.slice(0, 8).map((order) => (
|
||||||
|
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{order.documentNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} · {order.lineCount} lines</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<CrmContactsPanel
|
||||||
|
entity={entity}
|
||||||
|
ownerId={record.id}
|
||||||
|
contacts={record.contacts ?? []}
|
||||||
|
onContactsChange={(contacts: CrmContactDto[]) =>
|
||||||
|
setRecord((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
contacts,
|
||||||
|
rollups: {
|
||||||
|
lastContactAt: current.rollups?.lastContactAt ?? null,
|
||||||
|
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
|
||||||
|
contactCount: contacts.length,
|
||||||
|
attachmentCount: current.rollups?.attachmentCount ?? 0,
|
||||||
|
childCustomerCount: current.rollups?.childCustomerCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
|
||||||
|
{canManage ? (
|
||||||
|
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact History</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Add timeline entry</h4>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
Record calls, emails, meetings, and follow-up notes directly against this account.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<CrmContactEntryForm
|
||||||
|
form={contactEntryForm}
|
||||||
|
isSaving={isSavingContactEntry}
|
||||||
|
status={contactEntryStatus}
|
||||||
|
onChange={updateContactEntryField}
|
||||||
|
onSubmit={handleContactEntrySubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timeline</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Recent interactions</h4>
|
||||||
|
{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">
|
||||||
|
No contact history has been recorded for this account yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{record.contactHistory.map((entry) => (
|
||||||
|
<article key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<CrmContactTypeBadge type={entry.type} />
|
||||||
|
<h5 className="text-sm font-semibold text-text">{entry.summary}</h5>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{entry.body}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted lg:text-right">
|
||||||
|
<div>{new Date(entry.contactAt).toLocaleString()}</div>
|
||||||
|
<div className="mt-1">{entry.createdBy.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<CrmAttachmentsPanel
|
||||||
|
ownerType={config.fileOwnerType}
|
||||||
|
ownerId={record.id}
|
||||||
|
onAttachmentCountChange={(attachmentCount) =>
|
||||||
|
setRecord((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
rollups: {
|
||||||
|
lastContactAt: current.rollups?.lastContactAt ?? null,
|
||||||
|
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
|
||||||
|
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
|
||||||
|
attachmentCount,
|
||||||
|
childCustomerCount: current.rollups?.childCustomerCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
149
client/src/modules/crm/CrmFormPage.tsx
Normal file
149
client/src/modules/crm/CrmFormPage.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { CrmRecordForm } from "./CrmRecordForm";
|
||||||
|
import { type CrmEntity, crmConfigs, emptyCrmRecordInput } from "./config";
|
||||||
|
|
||||||
|
interface CrmFormPageProps {
|
||||||
|
entity: CrmEntity;
|
||||||
|
mode: "create" | "edit";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { customerId, vendorId } = useParams();
|
||||||
|
const recordId = entity === "customer" ? customerId : vendorId;
|
||||||
|
const config = crmConfigs[entity];
|
||||||
|
const [form, setForm] = useState<CrmRecordInput>(emptyCrmRecordInput);
|
||||||
|
const [hierarchyOptions, setHierarchyOptions] = useState<CrmCustomerHierarchyOptionDto[]>([]);
|
||||||
|
const [status, setStatus] = useState(
|
||||||
|
mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...`
|
||||||
|
);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entity !== "customer" || !token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getCustomerHierarchyOptions(token, mode === "edit" ? recordId : undefined)
|
||||||
|
.then(setHierarchyOptions)
|
||||||
|
.catch(() => setHierarchyOptions([]));
|
||||||
|
}, [entity, mode, recordId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "edit" || !token || !recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
|
||||||
|
|
||||||
|
loadRecord
|
||||||
|
.then((record) => {
|
||||||
|
setForm({
|
||||||
|
name: record.name,
|
||||||
|
email: record.email,
|
||||||
|
phone: record.phone,
|
||||||
|
addressLine1: record.addressLine1,
|
||||||
|
addressLine2: record.addressLine2,
|
||||||
|
city: record.city,
|
||||||
|
state: record.state,
|
||||||
|
postalCode: record.postalCode,
|
||||||
|
country: record.country,
|
||||||
|
status: record.status,
|
||||||
|
isReseller: record.isReseller ?? false,
|
||||||
|
resellerDiscountPercent: record.resellerDiscountPercent ?? 0,
|
||||||
|
parentCustomerId: record.parentCustomerId ?? null,
|
||||||
|
paymentTerms: record.paymentTerms ?? "Net 30",
|
||||||
|
currencyCode: record.currencyCode ?? "USD",
|
||||||
|
taxExempt: record.taxExempt ?? false,
|
||||||
|
creditHold: record.creditHold ?? false,
|
||||||
|
lifecycleStage: record.lifecycleStage ?? "ACTIVE",
|
||||||
|
preferredAccount: record.preferredAccount ?? false,
|
||||||
|
strategicAccount: record.strategicAccount ?? false,
|
||||||
|
requiresApproval: record.requiresApproval ?? false,
|
||||||
|
blockedAccount: record.blockedAccount ?? false,
|
||||||
|
notes: record.notes,
|
||||||
|
});
|
||||||
|
setStatus(`${config.singularLabel} record loaded.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [config.singularLabel, entity, mode, recordId, token]);
|
||||||
|
|
||||||
|
function updateField<Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) {
|
||||||
|
setForm((current: CrmRecordInput) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedRecord =
|
||||||
|
entity === "customer"
|
||||||
|
? mode === "create"
|
||||||
|
? await api.createCustomer(token, form)
|
||||||
|
: await api.updateCustomer(token, recordId ?? "", form)
|
||||||
|
: mode === "create"
|
||||||
|
? await api.createVendor(token, form)
|
||||||
|
: await api.updateVendor(token, recordId ?? "", form);
|
||||||
|
|
||||||
|
navigate(`${config.routeBase}/${savedRecord.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Editor</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">
|
||||||
|
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">
|
||||||
|
Capture the operational contact and address details needed for quoting, purchasing, and shipping workflows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<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">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel}` : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
22
client/src/modules/crm/CrmLifecycleBadge.tsx
Normal file
22
client/src/modules/crm/CrmLifecycleBadge.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { CrmLifecycleStage } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
|
||||||
|
import { crmLifecyclePalette } from "./config";
|
||||||
|
|
||||||
|
interface CrmLifecycleBadgeProps {
|
||||||
|
stage: CrmLifecycleStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<CrmLifecycleStage, string> = {
|
||||||
|
PROSPECT: "Prospect",
|
||||||
|
ACTIVE: "Active",
|
||||||
|
DORMANT: "Dormant",
|
||||||
|
CHURNED: "Churned",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CrmLifecycleBadge({ stage }: CrmLifecycleBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${crmLifecyclePalette[stage]}`}>
|
||||||
|
{labels[stage]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
client/src/modules/crm/CrmListPage.tsx
Normal file
212
client/src/modules/crm/CrmListPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { CrmLifecycleStage, CrmRecordStatus, CrmRecordSummaryDto } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
|
||||||
|
import { CrmStatusBadge } from "./CrmStatusBadge";
|
||||||
|
import { crmLifecycleFilters, crmOperationalFilters, crmStatusFilters, type CrmEntity, crmConfigs } from "./config";
|
||||||
|
|
||||||
|
interface CrmListPageProps {
|
||||||
|
entity: CrmEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrmListPage({ entity }: CrmListPageProps) {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const config = crmConfigs[entity];
|
||||||
|
const [records, setRecords] = useState<CrmRecordSummaryDto[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [stateFilter, setStateFilter] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | CrmRecordStatus>("ALL");
|
||||||
|
const [lifecycleFilter, setLifecycleFilter] = useState<"ALL" | CrmLifecycleStage>("ALL");
|
||||||
|
const [operationalFilter, setOperationalFilter] = useState<"ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED">(
|
||||||
|
"ALL"
|
||||||
|
);
|
||||||
|
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
q: searchTerm.trim() || undefined,
|
||||||
|
state: stateFilter.trim() || undefined,
|
||||||
|
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||||
|
lifecycleStage: lifecycleFilter === "ALL" ? undefined : lifecycleFilter,
|
||||||
|
flag: operationalFilter === "ALL" ? undefined : operationalFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRecords = entity === "customer" ? api.getCustomers(token, filters) : api.getVendors(token, filters);
|
||||||
|
|
||||||
|
loadRecords
|
||||||
|
.then((nextRecords) => {
|
||||||
|
setRecords(nextRecords);
|
||||||
|
setStatus(`${nextRecords.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">
|
||||||
|
Operational contact records, shipping addresses, and account context for active {config.collectionLabel.toLowerCase()}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link
|
||||||
|
to={`${config.routeBase}/new`}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
New {config.singularLabel.toLowerCase()}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-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]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder={`Search ${config.collectionLabel.toLowerCase()} by company, email, phone, or location`}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value as "ALL" | CrmRecordStatus)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{crmStatusFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Lifecycle</span>
|
||||||
|
<select
|
||||||
|
value={lifecycleFilter}
|
||||||
|
onChange={(event) => setLifecycleFilter(event.target.value as "ALL" | CrmLifecycleStage)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{crmLifecycleFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">State / Province</span>
|
||||||
|
<input
|
||||||
|
value={stateFilter}
|
||||||
|
onChange={(event) => setStateFilter(event.target.value)}
|
||||||
|
placeholder="Filter by region"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Operational</span>
|
||||||
|
<select
|
||||||
|
value={operationalFilter}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOperationalFilter(event.target.value as "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED")
|
||||||
|
}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{crmOperationalFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{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">
|
||||||
|
{config.emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Name</th>
|
||||||
|
<th className="px-2 py-2">Status</th>
|
||||||
|
<th className="px-2 py-2">Lifecycle</th>
|
||||||
|
<th className="px-2 py-2">Operational</th>
|
||||||
|
<th className="px-2 py-2">Activity</th>
|
||||||
|
<th className="px-2 py-2">Contact</th>
|
||||||
|
<th className="px-2 py-2">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{records.map((record) => (
|
||||||
|
<tr key={record.id} className="transition hover:bg-page/70">
|
||||||
|
<td className="px-2 py-2 font-semibold text-text">
|
||||||
|
<Link to={`${config.routeBase}/${record.id}`} className="hover:text-brand">
|
||||||
|
{record.name}
|
||||||
|
</Link>
|
||||||
|
{entity === "customer" && (record.isReseller || record.parentCustomerName) ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2 text-xs font-medium text-muted">
|
||||||
|
{record.isReseller ? <span>Reseller</span> : null}
|
||||||
|
{record.parentCustomerName ? <span>Child of {record.parentCustomerName}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<CrmStatusBadge status={record.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">
|
||||||
|
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-xs text-muted">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Preferred</span> : null}
|
||||||
|
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Strategic</span> : null}
|
||||||
|
{record.requiresApproval ? <span className="rounded-full border border-line/70 px-2 py-1">Approval</span> : null}
|
||||||
|
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-2 py-1 text-rose-600 dark:text-rose-300">Blocked</span> : null}
|
||||||
|
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
|
||||||
|
<span>Standard</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-xs text-muted">
|
||||||
|
<div>{record.rollups?.contactHistoryCount ?? 0} timeline entries</div>
|
||||||
|
<div>{record.rollups?.attachmentCount ?? 0} attachments</div>
|
||||||
|
{entity === "customer" ? <div>{record.rollups?.childCustomerCount ?? 0} child accounts</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">
|
||||||
|
<div>{record.email}</div>
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
{record.city}, {record.state}, {record.country}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs">{record.phone}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{new Date(record.updatedAt).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
199
client/src/modules/crm/CrmRecordForm.tsx
Normal file
199
client/src/modules/crm/CrmRecordForm.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
|
||||||
|
import { crmLifecycleOptions, crmStatusOptions } from "./config";
|
||||||
|
import type { CrmEntity } from "./config";
|
||||||
|
|
||||||
|
const fields: Array<{
|
||||||
|
key: "name" | "email" | "phone" | "addressLine1" | "addressLine2" | "city" | "state" | "postalCode" | "country";
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "name", label: "Company name" },
|
||||||
|
{ key: "email", label: "Email", type: "email" },
|
||||||
|
{ key: "phone", label: "Phone" },
|
||||||
|
{ key: "addressLine1", label: "Address line 1" },
|
||||||
|
{ key: "addressLine2", label: "Address line 2" },
|
||||||
|
{ key: "city", label: "City" },
|
||||||
|
{ key: "state", label: "State / Province" },
|
||||||
|
{ key: "postalCode", label: "Postal code" },
|
||||||
|
{ key: "country", label: "Country" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CrmRecordFormProps {
|
||||||
|
entity: CrmEntity;
|
||||||
|
form: CrmRecordInput;
|
||||||
|
hierarchyOptions?: CrmCustomerHierarchyOptionDto[];
|
||||||
|
onChange: <Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }: CrmRecordFormProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(event) => onChange("status", event.target.value as CrmRecordInput["status"])}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{crmStatusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Lifecycle stage</span>
|
||||||
|
<select
|
||||||
|
value={form.lifecycleStage ?? "ACTIVE"}
|
||||||
|
onChange={(event) => onChange("lifecycleStage", event.target.value as CrmRecordInput["lifecycleStage"])}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{crmLifecycleOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{entity === "customer" ? (
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Reseller account</span>
|
||||||
|
<select
|
||||||
|
value={form.isReseller ? "yes" : "no"}
|
||||||
|
onChange={(event) => onChange("isReseller", event.target.value === "yes")}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
<option value="no">Standard customer</option>
|
||||||
|
<option value="yes">Reseller</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Reseller discount %</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
value={form.resellerDiscountPercent ?? 0}
|
||||||
|
disabled={!form.isReseller}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange("resellerDiscountPercent", event.target.value === "" ? 0 : Number(event.target.value))
|
||||||
|
}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Parent reseller</span>
|
||||||
|
<select
|
||||||
|
value={form.parentCustomerId ?? ""}
|
||||||
|
onChange={(event) => onChange("parentCustomerId", event.target.value || null)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
<option value="">No parent reseller</option>
|
||||||
|
{hierarchyOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.name} {option.isReseller ? "(Reseller)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Payment terms</span>
|
||||||
|
<input
|
||||||
|
value={form.paymentTerms ?? ""}
|
||||||
|
onChange={(event) => onChange("paymentTerms", event.target.value || null)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
placeholder="Net 30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Currency</span>
|
||||||
|
<input
|
||||||
|
value={form.currencyCode ?? "USD"}
|
||||||
|
onChange={(event) => onChange("currencyCode", event.target.value.toUpperCase() || null)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
maxLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.taxExempt ?? false}
|
||||||
|
onChange={(event) => onChange("taxExempt", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-text">Tax exempt</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.creditHold ?? false}
|
||||||
|
onChange={(event) => onChange("creditHold", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-text">Credit hold</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.preferredAccount ?? false}
|
||||||
|
onChange={(event) => onChange("preferredAccount", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-text">Preferred account</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.strategicAccount ?? false}
|
||||||
|
onChange={(event) => onChange("strategicAccount", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-text">Strategic account</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.requiresApproval ?? false}
|
||||||
|
onChange={(event) => onChange("requiresApproval", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-text">Requires approval</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.blockedAccount ?? false}
|
||||||
|
onChange={(event) => onChange("blockedAccount", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-text">Blocked account</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<label key={String(field.key)} className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">{field.label}</span>
|
||||||
|
<input
|
||||||
|
type={field.type ?? "text"}
|
||||||
|
value={form[field.key]}
|
||||||
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Internal notes</span>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(event) => onChange("notes", event.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal file
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { CrmRecordStatus } from "@mrp/shared/dist/crm/types.js";
|
||||||
|
|
||||||
|
import { crmStatusOptions, crmStatusPalette } from "./config";
|
||||||
|
|
||||||
|
export function CrmStatusBadge({ status }: { status: CrmRecordStatus }) {
|
||||||
|
const label = crmStatusOptions.find((option) => option.value === status)?.label ?? status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${crmStatusPalette[status]}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
client/src/modules/crm/CustomersPage.tsx
Normal file
5
client/src/modules/crm/CustomersPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CrmListPage } from "./CrmListPage";
|
||||||
|
|
||||||
|
export function CustomersPage() {
|
||||||
|
return <CrmListPage entity="customer" />;
|
||||||
|
}
|
||||||
5
client/src/modules/crm/VendorsPage.tsx
Normal file
5
client/src/modules/crm/VendorsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CrmListPage } from "./CrmListPage";
|
||||||
|
|
||||||
|
export function VendorsPage() {
|
||||||
|
return <CrmListPage entity="vendor" />;
|
||||||
|
}
|
||||||
159
client/src/modules/crm/config.ts
Normal file
159
client/src/modules/crm/config.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
crmContactRoles,
|
||||||
|
crmContactEntryTypes,
|
||||||
|
crmLifecycleStages,
|
||||||
|
crmRecordStatuses,
|
||||||
|
type CrmContactInput,
|
||||||
|
type CrmContactRole,
|
||||||
|
type CrmContactEntryInput,
|
||||||
|
type CrmContactEntryType,
|
||||||
|
type CrmLifecycleStage,
|
||||||
|
type CrmRecordInput,
|
||||||
|
type CrmRecordStatus,
|
||||||
|
} from "@mrp/shared/dist/crm/types.js";
|
||||||
|
|
||||||
|
export type CrmEntity = "customer" | "vendor";
|
||||||
|
|
||||||
|
interface CrmModuleConfig {
|
||||||
|
entity: CrmEntity;
|
||||||
|
collectionLabel: string;
|
||||||
|
singularLabel: string;
|
||||||
|
routeBase: string;
|
||||||
|
fileOwnerType: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const crmConfigs: Record<CrmEntity, CrmModuleConfig> = {
|
||||||
|
customer: {
|
||||||
|
entity: "customer",
|
||||||
|
collectionLabel: "Customers",
|
||||||
|
singularLabel: "Customer",
|
||||||
|
routeBase: "/crm/customers",
|
||||||
|
fileOwnerType: "crm-customer",
|
||||||
|
emptyMessage: "No customer accounts have been added yet.",
|
||||||
|
},
|
||||||
|
vendor: {
|
||||||
|
entity: "vendor",
|
||||||
|
collectionLabel: "Vendors",
|
||||||
|
singularLabel: "Vendor",
|
||||||
|
routeBase: "/crm/vendors",
|
||||||
|
fileOwnerType: "crm-vendor",
|
||||||
|
emptyMessage: "No vendor records have been added yet.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyCrmRecordInput: CrmRecordInput = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
addressLine1: "",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
postalCode: "",
|
||||||
|
country: "USA",
|
||||||
|
status: "ACTIVE",
|
||||||
|
notes: "",
|
||||||
|
isReseller: false,
|
||||||
|
resellerDiscountPercent: 0,
|
||||||
|
parentCustomerId: null,
|
||||||
|
paymentTerms: "Net 30",
|
||||||
|
currencyCode: "USD",
|
||||||
|
taxExempt: false,
|
||||||
|
creditHold: false,
|
||||||
|
lifecycleStage: "ACTIVE",
|
||||||
|
preferredAccount: false,
|
||||||
|
strategicAccount: false,
|
||||||
|
requiresApproval: false,
|
||||||
|
blockedAccount: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [
|
||||||
|
{ value: "LEAD", label: "Lead" },
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "ON_HOLD", label: "On Hold" },
|
||||||
|
{ value: "INACTIVE", label: "Inactive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const crmStatusFilters: Array<{ value: "ALL" | CrmRecordStatus; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All statuses" },
|
||||||
|
...crmStatusOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const crmLifecycleOptions: Array<{ value: CrmLifecycleStage; label: string }> = [
|
||||||
|
{ value: "PROSPECT", label: "Prospect" },
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "DORMANT", label: "Dormant" },
|
||||||
|
{ value: "CHURNED", label: "Churned" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const crmLifecycleFilters: Array<{ value: "ALL" | CrmLifecycleStage; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All lifecycle stages" },
|
||||||
|
...crmLifecycleOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const crmOperationalFilters: Array<{
|
||||||
|
value: "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "ALL", label: "All accounts" },
|
||||||
|
{ value: "PREFERRED", label: "Preferred only" },
|
||||||
|
{ value: "STRATEGIC", label: "Strategic only" },
|
||||||
|
{ value: "REQUIRES_APPROVAL", label: "Requires approval" },
|
||||||
|
{ value: "BLOCKED", label: "Blocked only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const crmStatusPalette: Record<CrmRecordStatus, string> = {
|
||||||
|
LEAD: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
INACTIVE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const crmLifecyclePalette: Record<CrmLifecycleStage, string> = {
|
||||||
|
PROSPECT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
DORMANT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
CHURNED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyCrmContactEntryInput: CrmContactEntryInput = {
|
||||||
|
type: "NOTE",
|
||||||
|
summary: "",
|
||||||
|
body: "",
|
||||||
|
contactAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyCrmContactInput: CrmContactInput = {
|
||||||
|
fullName: "",
|
||||||
|
role: "PRIMARY",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
isPrimary: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const crmContactTypeOptions: Array<{ value: CrmContactEntryType; label: string }> = [
|
||||||
|
{ value: "NOTE", label: "Note" },
|
||||||
|
{ value: "CALL", label: "Call" },
|
||||||
|
{ value: "EMAIL", label: "Email" },
|
||||||
|
{ value: "MEETING", label: "Meeting" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const crmContactTypePalette: Record<CrmContactEntryType, string> = {
|
||||||
|
NOTE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
CALL: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
EMAIL: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||||
|
MEETING: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const crmContactRoleOptions: Array<{ value: CrmContactRole; label: string }> = [
|
||||||
|
{ value: "PRIMARY", label: "Primary" },
|
||||||
|
{ value: "PURCHASING", label: "Purchasing" },
|
||||||
|
{ value: "AP", label: "Accounts Payable" },
|
||||||
|
{ value: "SHIPPING", label: "Shipping" },
|
||||||
|
{ value: "ENGINEERING", label: "Engineering" },
|
||||||
|
{ value: "SALES", label: "Sales" },
|
||||||
|
{ value: "OTHER", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses };
|
||||||
593
client/src/modules/dashboard/DashboardPage.tsx
Normal file
593
client/src/modules/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { ApiError, api } from "../../lib/api";
|
||||||
|
|
||||||
|
interface DashboardSnapshot {
|
||||||
|
customers: Awaited<ReturnType<typeof api.getCustomers>> | null;
|
||||||
|
vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
|
||||||
|
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
|
||||||
|
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
|
||||||
|
purchaseOrders: Awaited<ReturnType<typeof api.getPurchaseOrders>> | null;
|
||||||
|
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
|
||||||
|
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
|
||||||
|
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
|
||||||
|
shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
|
||||||
|
projects: Awaited<ReturnType<typeof api.getProjects>> | null;
|
||||||
|
planningRollup: DemandPlanningRollupDto | null;
|
||||||
|
refreshedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(userPermissions: string[] | undefined, permission: string) {
|
||||||
|
return Boolean(userPermissions?.includes(permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumNumber(values: number[]) {
|
||||||
|
return values.reduce((total, value) => total + value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number, total: number) {
|
||||||
|
if (total <= 0) {
|
||||||
|
return "0%";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round((value / total) * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({
|
||||||
|
value,
|
||||||
|
total,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
tone: string;
|
||||||
|
}) {
|
||||||
|
const width = total > 0 ? Math.max(6, Math.round((value / total) * 100)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-page/80">
|
||||||
|
<div className={`h-full rounded-full ${tone}`} style={{ width: `${Math.min(width, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackedBar({
|
||||||
|
segments,
|
||||||
|
}: {
|
||||||
|
segments: Array<{ value: number; tone: string }>;
|
||||||
|
}) {
|
||||||
|
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-3 overflow-hidden rounded-full bg-page/80">
|
||||||
|
{segments.map((segment, index) => {
|
||||||
|
const width = total > 0 ? (segment.value / total) * 100 : 0;
|
||||||
|
return <div key={`${segment.tone}-${index}`} className={segment.tone} style={{ width: `${width}%` }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardCard({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className={`rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${className}`.trim()}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !user) {
|
||||||
|
setSnapshot(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = token;
|
||||||
|
let isMounted = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const canReadCrm = hasPermission(user.permissions, permissions.crmRead);
|
||||||
|
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
|
||||||
|
const canReadPurchasing = hasPermission(user.permissions, permissions.purchasingRead);
|
||||||
|
const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
|
||||||
|
const canReadSales = hasPermission(user.permissions, permissions.salesRead);
|
||||||
|
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
|
||||||
|
const canReadProjects = hasPermission(user.permissions, permissions.projectsRead);
|
||||||
|
|
||||||
|
async function loadSnapshot() {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
canReadCrm ? api.getCustomers(authToken) : Promise.resolve(null),
|
||||||
|
canReadCrm ? api.getVendors(authToken) : Promise.resolve(null),
|
||||||
|
canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null),
|
||||||
|
canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null),
|
||||||
|
canReadPurchasing ? api.getPurchaseOrders(authToken) : Promise.resolve(null),
|
||||||
|
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
|
||||||
|
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
|
||||||
|
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
|
||||||
|
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
|
||||||
|
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
|
||||||
|
canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRejected = results.find((result) => result.status === "rejected");
|
||||||
|
if (firstRejected?.status === "rejected") {
|
||||||
|
const reason = firstRejected.reason;
|
||||||
|
setError(reason instanceof ApiError ? reason.message : "Unable to load dashboard data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot({
|
||||||
|
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
||||||
|
vendors: results[1].status === "fulfilled" ? results[1].value : null,
|
||||||
|
items: results[2].status === "fulfilled" ? results[2].value : null,
|
||||||
|
warehouses: results[3].status === "fulfilled" ? results[3].value : null,
|
||||||
|
purchaseOrders: results[4].status === "fulfilled" ? results[4].value : null,
|
||||||
|
workOrders: results[5].status === "fulfilled" ? results[5].value : null,
|
||||||
|
quotes: results[6].status === "fulfilled" ? results[6].value : null,
|
||||||
|
orders: results[7].status === "fulfilled" ? results[7].value : null,
|
||||||
|
shipments: results[8].status === "fulfilled" ? results[8].value : null,
|
||||||
|
projects: results[9].status === "fulfilled" ? results[9].value : null,
|
||||||
|
planningRollup: results[10].status === "fulfilled" ? results[10].value : null,
|
||||||
|
refreshedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSnapshot().catch((loadError) => {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(loadError instanceof ApiError ? loadError.message : "Unable to load dashboard data.");
|
||||||
|
setSnapshot(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [token, user]);
|
||||||
|
|
||||||
|
const customers = snapshot?.customers ?? [];
|
||||||
|
const vendors = snapshot?.vendors ?? [];
|
||||||
|
const items = snapshot?.items ?? [];
|
||||||
|
const warehouses = snapshot?.warehouses ?? [];
|
||||||
|
const purchaseOrders = snapshot?.purchaseOrders ?? [];
|
||||||
|
const workOrders = snapshot?.workOrders ?? [];
|
||||||
|
const quotes = snapshot?.quotes ?? [];
|
||||||
|
const orders = snapshot?.orders ?? [];
|
||||||
|
const shipments = snapshot?.shipments ?? [];
|
||||||
|
const projects = snapshot?.projects ?? [];
|
||||||
|
const planningRollup = snapshot?.planningRollup;
|
||||||
|
|
||||||
|
const customerCount = customers.length;
|
||||||
|
const activeCustomerCount = customers.filter((customer) => customer.lifecycleStage === "ACTIVE").length;
|
||||||
|
const resellerCount = customers.filter((customer) => customer.isReseller).length;
|
||||||
|
const strategicCustomerCount = customers.filter((customer) => customer.strategicAccount).length;
|
||||||
|
const vendorCount = vendors.length;
|
||||||
|
|
||||||
|
const itemCount = items.length;
|
||||||
|
const activeItemCount = items.filter((item) => item.status === "ACTIVE").length;
|
||||||
|
const assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length;
|
||||||
|
const obsoleteItemCount = items.filter((item) => item.status === "OBSOLETE").length;
|
||||||
|
const warehouseCount = warehouses.length;
|
||||||
|
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
|
||||||
|
|
||||||
|
const purchaseOrderCount = purchaseOrders.length;
|
||||||
|
const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length;
|
||||||
|
const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
|
||||||
|
const closedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "CLOSED").length;
|
||||||
|
const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total));
|
||||||
|
|
||||||
|
const workOrderCount = workOrders.length;
|
||||||
|
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
|
||||||
|
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
|
||||||
|
const inProgressWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "IN_PROGRESS").length;
|
||||||
|
const overdueWorkOrderCount = workOrders.filter((workOrder) => workOrder.dueDate && workOrder.status !== "COMPLETE" && workOrder.status !== "CANCELLED" && new Date(workOrder.dueDate).getTime() < Date.now()).length;
|
||||||
|
|
||||||
|
const quoteCount = quotes.length;
|
||||||
|
const orderCount = orders.length;
|
||||||
|
const draftQuoteCount = quotes.filter((quote) => quote.status === "DRAFT").length;
|
||||||
|
const approvedQuoteCount = quotes.filter((quote) => quote.status === "APPROVED").length;
|
||||||
|
const issuedOrderCount = orders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
|
||||||
|
const quoteValue = sumNumber(quotes.map((quote) => quote.total));
|
||||||
|
const orderValue = sumNumber(orders.map((order) => order.total));
|
||||||
|
|
||||||
|
const shipmentCount = shipments.length;
|
||||||
|
const activeShipmentCount = shipments.filter((shipment) => shipment.status !== "DELIVERED").length;
|
||||||
|
const inTransitCount = shipments.filter((shipment) => shipment.status === "SHIPPED").length;
|
||||||
|
const deliveredCount = shipments.filter((shipment) => shipment.status === "DELIVERED").length;
|
||||||
|
|
||||||
|
const projectCount = projects.length;
|
||||||
|
const activeProjectCount = projects.filter((project) => project.status === "ACTIVE").length;
|
||||||
|
const atRiskProjectCount = projects.filter((project) => project.status === "AT_RISK").length;
|
||||||
|
const overdueProjectCount = projects.filter((project) => {
|
||||||
|
if (!project.dueDate || project.status === "COMPLETE") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(project.dueDate).getTime() < Date.now();
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0;
|
||||||
|
const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0;
|
||||||
|
const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0;
|
||||||
|
const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0;
|
||||||
|
const planningItemCount = planningRollup?.summary.itemCount ?? 0;
|
||||||
|
|
||||||
|
const metricCards = [
|
||||||
|
{
|
||||||
|
label: "Accounts",
|
||||||
|
value: snapshot?.customers !== null ? `${customerCount + vendorCount}` : "No access",
|
||||||
|
secondary: snapshot?.customers !== null ? `${activeCustomerCount} active customers` : "",
|
||||||
|
tone: "bg-emerald-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory",
|
||||||
|
value: snapshot?.items !== null ? `${itemCount}` : "No access",
|
||||||
|
secondary: snapshot?.items !== null ? `${assemblyCount} buildable items` : "",
|
||||||
|
tone: "bg-sky-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open Supply",
|
||||||
|
value: snapshot?.purchaseOrders !== null || snapshot?.workOrders !== null ? `${openPurchaseOrderCount + activeWorkOrderCount}` : "No access",
|
||||||
|
secondary: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount} PO | ${activeWorkOrderCount} WO` : "",
|
||||||
|
tone: "bg-teal-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Commercial",
|
||||||
|
value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access",
|
||||||
|
secondary: snapshot?.orders !== null ? `${orderCount} orders live` : "",
|
||||||
|
tone: "bg-amber-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Projects",
|
||||||
|
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
|
||||||
|
secondary: snapshot?.projects !== null ? `${atRiskProjectCount} at risk` : "",
|
||||||
|
tone: "bg-violet-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Readiness",
|
||||||
|
value: planningRollup ? `${shortageItemCount}` : "No access",
|
||||||
|
secondary: planningRollup ? `${totalUncoveredQuantity} units uncovered` : "",
|
||||||
|
tone: "bg-rose-500",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{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}
|
||||||
|
<section className="grid gap-3 xl:grid-cols-6">
|
||||||
|
{metricCards.map((card) => (
|
||||||
|
<article key={card.label} 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">{card.label}</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-page/80">
|
||||||
|
<div className={`h-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted">Live</span>
|
||||||
|
</div>
|
||||||
|
{card.secondary ? <div className="mt-2 text-xs text-muted">{card.secondary}</div> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<DashboardCard eyebrow="Commercial Surface" title="Revenue and document mix">
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-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">Quotes</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>Draft</span>
|
||||||
|
<span>{draftQuoteCount}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={draftQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-amber-500" />
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>Approved</span>
|
||||||
|
<span>{approvedQuoteCount}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Orders</div>
|
||||||
|
<div className="mt-2 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>
|
||||||
|
<div className="text-xs text-muted">Issued / approved</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">Total orders</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-text">{orderCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
<DashboardCard eyebrow="CRM Footprint" title="Customer and vendor balance">
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted">Active customers</span>
|
||||||
|
<span className="font-semibold text-text">{snapshot?.customers !== null ? formatPercent(activeCustomerCount, Math.max(customerCount, 1)) : "No access"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Customers</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{customerCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Resellers</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{resellerCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Vendors</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{vendorCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted">Strategic accounts</span>
|
||||||
|
<span className="font-semibold text-text">{strategicCustomerCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={strategicCustomerCount} total={Math.max(customerCount, 1)} tone="bg-violet-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<DashboardCard eyebrow="Inventory and Supply" title="Stock posture">
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-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">Item mix</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>Active items</span>
|
||||||
|
<span>{activeItemCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={activeItemCount} total={Math.max(itemCount, 1)} tone="bg-sky-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>Buildable items</span>
|
||||||
|
<span>{assemblyCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={assemblyCount} total={Math.max(itemCount, 1)} tone="bg-indigo-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>Obsolete items</span>
|
||||||
|
<span>{obsoleteItemCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={obsoleteItemCount} total={Math.max(itemCount, 1)} tone="bg-slate-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Storage surface</div>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs text-muted">Warehouses</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs text-muted">Locations</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{locationCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
<DashboardCard eyebrow="Supply Execution" title="Purchasing and manufacturing flow">
|
||||||
|
<div className="mt-4 rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Open workload split</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<StackedBar
|
||||||
|
segments={[
|
||||||
|
{ value: openPurchaseOrderCount, tone: "bg-teal-500" },
|
||||||
|
{ value: releasedWorkOrderCount, tone: "bg-indigo-500" },
|
||||||
|
{ value: inProgressWorkOrderCount, tone: "bg-amber-500" },
|
||||||
|
{ value: overdueWorkOrderCount, tone: "bg-rose-500" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs text-muted">Open PO queue</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{openPurchaseOrderCount}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs text-muted">Active work orders</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{activeWorkOrderCount}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs text-muted">Issued / approved POs</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs text-muted">Released WOs</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
<DashboardCard eyebrow="Readiness" title="Planning pressure">
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted">Shortage items</span>
|
||||||
|
<span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Build vs buy</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<StackedBar
|
||||||
|
segments={[
|
||||||
|
{ value: buildRecommendationCount, tone: "bg-indigo-500" },
|
||||||
|
{ value: buyRecommendationCount, tone: "bg-teal-500" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">Build recommendations</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buildRecommendationCount : "No access"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">Buy recommendations</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buyRecommendationCount : "No access"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="text-xs text-muted">Uncovered quantity</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? totalUncoveredQuantity : "No access"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<DashboardCard eyebrow="Programs" title="Project and shipment execution">
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-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">Projects</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<StackedBar
|
||||||
|
segments={[
|
||||||
|
{ value: activeProjectCount, tone: "bg-violet-500" },
|
||||||
|
{ value: atRiskProjectCount, tone: "bg-amber-500" },
|
||||||
|
{ value: overdueProjectCount, tone: "bg-rose-500" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">Active</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{activeProjectCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">At risk</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{atRiskProjectCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">Overdue</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{overdueProjectCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipping</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<StackedBar
|
||||||
|
segments={[
|
||||||
|
{ value: activeShipmentCount, tone: "bg-brand" },
|
||||||
|
{ value: inTransitCount, tone: "bg-sky-500" },
|
||||||
|
{ value: deliveredCount, tone: "bg-emerald-500" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">Open</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{activeShipmentCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">In transit</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{inTransitCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted">Delivered</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{deliveredCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
<DashboardCard eyebrow="Operations Mix" title="Cross-module volume">
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{[
|
||||||
|
{ label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" },
|
||||||
|
{ label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" },
|
||||||
|
{ label: "Sales orders", value: orderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-amber-500" },
|
||||||
|
{ label: "Purchase orders", value: purchaseOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-teal-500" },
|
||||||
|
{ label: "Work orders", value: workOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-indigo-500" },
|
||||||
|
{ label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" },
|
||||||
|
{ label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" },
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.label} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted">{row.label}</span>
|
||||||
|
<span className="font-semibold text-text">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={row.value} total={row.total} tone={row.tone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{snapshot ? <div className="mt-4 text-xs text-muted">Refreshed {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null}
|
||||||
|
</DashboardCard>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
client/src/modules/gantt/GanttPage.tsx
Normal file
196
client/src/modules/gantt/GanttPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Gantt } from "@svar-ui/react-gantt";
|
||||||
|
import "@svar-ui/react-gantt/style.css";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { ApiError, api } from "../../lib/api";
|
||||||
|
import { useTheme } from "../../theme/ThemeProvider";
|
||||||
|
|
||||||
|
function formatDate(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "Unscheduled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GanttPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { mode } = useTheme();
|
||||||
|
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
|
||||||
|
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
|
||||||
|
const [status, setStatus] = useState("Loading live planning timeline...");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
|
||||||
|
.then(([data, rollup]) => {
|
||||||
|
setTimeline(data);
|
||||||
|
setPlanningRollup(rollup);
|
||||||
|
setStatus("Planning timeline loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const tasks = timeline?.tasks ?? [];
|
||||||
|
const links = timeline?.links ?? [];
|
||||||
|
const summary = timeline?.summary;
|
||||||
|
const exceptions = timeline?.exceptions ?? [];
|
||||||
|
const ganttCellHeight = 44;
|
||||||
|
const ganttScaleHeight = 56;
|
||||||
|
const ganttHeight = Math.max(420, tasks.length * ganttCellHeight + ganttScaleHeight);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-bold text-text">Live Project + Manufacturing Gantt</h3>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||||
|
The planning surface now reads directly from active projects and open work orders so schedule pressure, due-date risk, and standalone manufacturing load are visible in one place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Status</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">{status}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="grid gap-3 xl:grid-cols-6">
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Projects</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeProjects ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">At Risk</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.atRiskProjects ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Projects</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueProjects ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeWorkOrders ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueWorkOrders ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{planningRollup?.summary.uncoveredItemCount ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build / Buy</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">
|
||||||
|
{planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||||
|
<div
|
||||||
|
className={`gantt-theme overflow-x-auto overflow-y-visible rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${
|
||||||
|
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
{tasks.length} schedule rows
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: `${ganttHeight}px`, minWidth: "100%" }}>
|
||||||
|
<Gantt
|
||||||
|
tasks={tasks.map((task: GanttTaskDto) => ({
|
||||||
|
...task,
|
||||||
|
start: new Date(task.start),
|
||||||
|
end: new Date(task.end),
|
||||||
|
parent: task.parentId ?? undefined,
|
||||||
|
}))}
|
||||||
|
links={links}
|
||||||
|
cellHeight={ganttCellHeight}
|
||||||
|
scaleHeight={ganttScaleHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="space-y-3">
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning Exceptions</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Priority schedule issues from live project due dates and manufacturing commitments.</p>
|
||||||
|
{exceptions.length === 0 ? (
|
||||||
|
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||||
|
No planning exceptions are active.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{exceptions.map((exception: PlanningExceptionDto) => (
|
||||||
|
<Link key={exception.id} to={exception.detailHref} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{exception.title}</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||||
|
{exception.status.replaceAll("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
|
||||||
|
<div className="mt-4 space-y-2 rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted">Uncovered quantity</span>
|
||||||
|
<span className="font-semibold text-text">{planningRollup?.summary.totalUncoveredQuantity ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted">Projects with linked demand</span>
|
||||||
|
<span className="font-semibold text-text">{planningRollup?.summary.projectCount ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Open projects
|
||||||
|
</Link>
|
||||||
|
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Open work orders
|
||||||
|
</Link>
|
||||||
|
<Link to="/" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
22
client/src/modules/inventory/InventoryAttachmentsPanel.tsx
Normal file
22
client/src/modules/inventory/InventoryAttachmentsPanel.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
|
import { inventoryFileOwnerType } from "./config";
|
||||||
|
|
||||||
|
export function InventoryAttachmentsPanel({
|
||||||
|
itemId,
|
||||||
|
onAttachmentCountChange,
|
||||||
|
}: {
|
||||||
|
itemId: string;
|
||||||
|
onAttachmentCountChange?: (count: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FileAttachmentsPanel
|
||||||
|
ownerType={inventoryFileOwnerType}
|
||||||
|
ownerId={itemId}
|
||||||
|
eyebrow="Attachments"
|
||||||
|
title="Drawings and support docs"
|
||||||
|
description="Store drawings, cut sheets, work instructions, and other manufacturing support files on the item record."
|
||||||
|
emptyMessage="No drawings or support documents have been added to this item yet."
|
||||||
|
onAttachmentCountChange={onAttachmentCountChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
722
client/src/modules/inventory/InventoryDetailPage.tsx
Normal file
722
client/src/modules/inventory/InventoryDetailPage.tsx
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
import type {
|
||||||
|
InventoryItemDetailDto,
|
||||||
|
InventoryReservationInput,
|
||||||
|
InventoryTransactionInput,
|
||||||
|
InventoryTransferInput,
|
||||||
|
WarehouseLocationOptionDto,
|
||||||
|
} from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import type { FileAttachmentDto } from "@mrp/shared";
|
||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { emptyInventoryTransactionInput, inventoryThumbnailOwnerType, inventoryTransactionOptions } from "./config";
|
||||||
|
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
|
||||||
|
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
||||||
|
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
|
||||||
|
import { InventoryTypeBadge } from "./InventoryTypeBadge";
|
||||||
|
|
||||||
|
const emptyTransferInput: InventoryTransferInput = {
|
||||||
|
quantity: 1,
|
||||||
|
fromWarehouseId: "",
|
||||||
|
fromLocationId: "",
|
||||||
|
toWarehouseId: "",
|
||||||
|
toLocationId: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyReservationInput: InventoryReservationInput = {
|
||||||
|
quantity: 1,
|
||||||
|
warehouseId: null,
|
||||||
|
locationId: null,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InventoryDetailPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { itemId } = useParams();
|
||||||
|
const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
|
||||||
|
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||||
|
const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput);
|
||||||
|
const [transferForm, setTransferForm] = useState<InventoryTransferInput>(emptyTransferInput);
|
||||||
|
const [reservationForm, setReservationForm] = useState<InventoryReservationInput>(emptyReservationInput);
|
||||||
|
const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
|
||||||
|
const [transferStatus, setTransferStatus] = useState("Move physical stock between warehouses or locations without manual paired entries.");
|
||||||
|
const [reservationStatus, setReservationStatus] = useState("Reserve stock manually while active work orders reserve component demand automatically.");
|
||||||
|
const [isSavingTransaction, setIsSavingTransaction] = useState(false);
|
||||||
|
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
|
||||||
|
const [isSavingReservation, setIsSavingReservation] = useState(false);
|
||||||
|
const [status, setStatus] = useState("Loading inventory item...");
|
||||||
|
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
|
||||||
|
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "transaction" | "transfer" | "reservation";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||||
|
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||||
|
|
||||||
|
function replaceThumbnailPreview(nextUrl: string | null) {
|
||||||
|
setThumbnailPreviewUrl((current) => {
|
||||||
|
if (current) {
|
||||||
|
window.URL.revokeObjectURL(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getInventoryItem(token, itemId)
|
||||||
|
.then((nextItem) => {
|
||||||
|
setItem(nextItem);
|
||||||
|
setStatus("Inventory item loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
api
|
||||||
|
.getWarehouseLocationOptions(token)
|
||||||
|
.then((options) => {
|
||||||
|
setLocationOptions(options);
|
||||||
|
const firstOption = options[0];
|
||||||
|
if (!firstOption) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransactionForm((current) => ({
|
||||||
|
...current,
|
||||||
|
warehouseId: current.warehouseId || firstOption.warehouseId,
|
||||||
|
locationId: current.locationId || firstOption.locationId,
|
||||||
|
}));
|
||||||
|
setTransferForm((current) => ({
|
||||||
|
...current,
|
||||||
|
fromWarehouseId: current.fromWarehouseId || firstOption.warehouseId,
|
||||||
|
fromLocationId: current.fromLocationId || firstOption.locationId,
|
||||||
|
toWarehouseId: current.toWarehouseId || firstOption.warehouseId,
|
||||||
|
toLocationId: current.toLocationId || firstOption.locationId,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => setLocationOptions([]));
|
||||||
|
}, [itemId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (thumbnailPreviewUrl) {
|
||||||
|
window.URL.revokeObjectURL(thumbnailPreviewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [thumbnailPreviewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !itemId || !canReadFiles) {
|
||||||
|
setThumbnailAttachment(null);
|
||||||
|
replaceThumbnailPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const activeToken: string = token;
|
||||||
|
const activeItemId: string = itemId;
|
||||||
|
|
||||||
|
async function loadThumbnail() {
|
||||||
|
const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId);
|
||||||
|
const latestAttachment = attachments[0] ?? null;
|
||||||
|
|
||||||
|
if (!latestAttachment) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setThumbnailAttachment(null);
|
||||||
|
replaceThumbnailPreview(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await api.getFileContentBlob(activeToken, latestAttachment.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setThumbnailAttachment(latestAttachment);
|
||||||
|
replaceThumbnailPreview(window.URL.createObjectURL(blob));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadThumbnail().catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setThumbnailAttachment(null);
|
||||||
|
replaceThumbnailPreview(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [canReadFiles, itemId, token]);
|
||||||
|
|
||||||
|
function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) {
|
||||||
|
setTransactionForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTransferField<Key extends keyof InventoryTransferInput>(key: Key, value: InventoryTransferInput[Key]) {
|
||||||
|
setTransferForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTransaction() {
|
||||||
|
if (!token || !itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingTransaction(true);
|
||||||
|
setTransactionStatus("Saving stock transaction...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
|
||||||
|
setItem(nextItem);
|
||||||
|
setTransactionStatus("Stock transaction recorded. If this was posted in error, create an offsetting stock entry and verify the result in Recent Movements.");
|
||||||
|
setTransactionForm((current) => ({
|
||||||
|
...emptyInventoryTransactionInput,
|
||||||
|
transactionType: current.transactionType,
|
||||||
|
warehouseId: current.warehouseId,
|
||||||
|
locationId: current.locationId,
|
||||||
|
}));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save stock transaction.";
|
||||||
|
setTransactionStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSavingTransaction(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTransfer() {
|
||||||
|
if (!token || !itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingTransfer(true);
|
||||||
|
setTransferStatus("Saving transfer...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
|
||||||
|
setItem(nextItem);
|
||||||
|
setTransferStatus("Transfer recorded. Review stock balances on both locations and post a return transfer if this movement was entered incorrectly.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
|
||||||
|
setTransferStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSavingTransfer(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReservation() {
|
||||||
|
if (!token || !itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingReservation(true);
|
||||||
|
setReservationStatus("Saving reservation...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
|
||||||
|
setItem(nextItem);
|
||||||
|
setReservationStatus("Reservation recorded. Verify available stock and add a compensating reservation change if this demand hold was entered incorrectly.");
|
||||||
|
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
|
||||||
|
setReservationStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSavingReservation(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionLabel = inventoryTransactionOptions.find((option) => option.value === transactionForm.transactionType)?.label ?? "transaction";
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "transaction",
|
||||||
|
title: `Post ${transactionLabel.toLowerCase()}`,
|
||||||
|
description: `Post a ${transactionLabel.toLowerCase()} of ${transactionForm.quantity} units for ${item.sku} at the selected stock location.`,
|
||||||
|
impact:
|
||||||
|
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||||
|
? "This reduces available inventory immediately and affects downstream shortage and readiness calculations."
|
||||||
|
: "This updates the stock ledger immediately and becomes part of the item transaction history.",
|
||||||
|
recovery: "If this is incorrect, post an explicit offsetting transaction instead of editing history.",
|
||||||
|
confirmLabel: `Post ${transactionLabel.toLowerCase()}`,
|
||||||
|
confirmationLabel:
|
||||||
|
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||||
|
? "Type item SKU to confirm:"
|
||||||
|
: undefined,
|
||||||
|
confirmationValue:
|
||||||
|
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
|
||||||
|
? item.sku
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "transfer",
|
||||||
|
title: "Post inventory transfer",
|
||||||
|
description: `Move ${transferForm.quantity} units of ${item.sku} between the selected source and destination locations.`,
|
||||||
|
impact: "This creates paired stock movement entries and changes both source and destination availability immediately.",
|
||||||
|
recovery: "If the move was entered incorrectly, post a reversing transfer back to the original location.",
|
||||||
|
confirmLabel: "Post transfer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "reservation",
|
||||||
|
title: "Create manual reservation",
|
||||||
|
description: `Reserve ${reservationForm.quantity} units of ${item.sku}${reservationForm.locationId ? " at the selected location" : ""}.`,
|
||||||
|
impact: "This reduces available quantity used by planning, purchasing, manufacturing, and readiness views.",
|
||||||
|
recovery: "Add the correcting reservation entry if this hold should be reduced or removed.",
|
||||||
|
confirmLabel: "Create reservation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Detail</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{item.sku}</h3>
|
||||||
|
<p className="mt-1 text-sm text-text">{item.name}</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<InventoryTypeBadge type={item.type} />
|
||||||
|
<InventoryStatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Back to items
|
||||||
|
</Link>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`/inventory/items/${item.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
|
Edit item
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="grid gap-3 xl:grid-cols-7">
|
||||||
|
<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">On Hand</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</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">Reserved</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</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">Available</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</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">Stock Locations</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.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">Transactions</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.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">Transfers</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{item.transfers.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">Reservations</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
|
||||||
|
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
|
||||||
|
<dd className="mt-1 text-sm leading-6 text-text">{item.description || "No description provided."}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unit of measure</dt>
|
||||||
|
<dd className="mt-2 text-sm text-text">{item.unitOfMeasure}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default cost</dt>
|
||||||
|
<dd className="mt-2 text-sm text-text">{item.defaultCost == null ? "Not set" : `$${item.defaultCost.toFixed(2)}`}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt>
|
||||||
|
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Preferred vendor</dt>
|
||||||
|
<dd className="mt-2 text-sm text-text">{item.preferredVendorName ?? "Not set"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
|
||||||
|
<dd className="mt-2 text-sm text-text">
|
||||||
|
{item.isSellable ? "Sellable" : "Not sellable"} / {item.isPurchasable ? "Purchasable" : "Not purchasable"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Thumbnail</p>
|
||||||
|
<div className="mt-4 overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
|
||||||
|
{thumbnailPreviewUrl ? (
|
||||||
|
<img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-square items-center justify-center px-4 text-center text-sm text-muted">
|
||||||
|
No thumbnail image has been attached to this item.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-muted">
|
||||||
|
{thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock By Location</p>
|
||||||
|
{item.stockBalances.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-muted">No stock or reservation balances have been posted for this item yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{item.stockBalances.map((balance) => (
|
||||||
|
<div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-text">
|
||||||
|
{balance.warehouseCode} / {balance.locationCode}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{balance.warehouseName} / {balance.locationName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-semibold text-text">{balance.quantityOnHand} on hand</div>
|
||||||
|
<div className="text-xs text-muted">{balance.quantityReserved} reserved / {balance.quantityAvailable} available</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
|
{canManage ? (
|
||||||
|
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
|
||||||
|
<select value={transactionForm.transactionType} onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{inventoryTransactionOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
|
<input type="number" min={1} step={1} value={transactionForm.quantity} onChange={(event) => updateTransactionField("quantity", Number.parseInt(event.target.value, 10) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
|
||||||
|
<select value={transactionForm.locationId} onChange={(event) => {
|
||||||
|
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
|
||||||
|
updateTransactionField("locationId", event.target.value);
|
||||||
|
if (nextLocation) {
|
||||||
|
updateTransactionField("warehouseId", nextLocation.warehouseId);
|
||||||
|
}
|
||||||
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{locationOptions.map((option) => (
|
||||||
|
<option key={option.locationId} value={option.locationId}>
|
||||||
|
{option.warehouseCode} / {option.locationCode}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Reference</span>
|
||||||
|
<input value={transactionForm.reference} onChange={(event) => updateTransactionField("reference", event.target.value)} placeholder="PO, WO, adjustment note, etc." className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
|
<span className="text-sm text-muted">{transactionStatus}</span>
|
||||||
|
<button type="submit" disabled={isSavingTransaction} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSavingTransaction ? "Posting..." : "Post transaction"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Movements</p>
|
||||||
|
{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">
|
||||||
|
No stock transactions have been recorded for this item yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{item.recentTransactions.map((transaction) => (
|
||||||
|
<article key={transaction.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<InventoryTransactionTypeBadge type={transaction.transactionType} />
|
||||||
|
<span className={`text-sm font-semibold ${transaction.signedQuantity >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
|
||||||
|
{transaction.signedQuantity >= 0 ? "+" : ""}
|
||||||
|
{transaction.signedQuantity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-semibold text-text">
|
||||||
|
{transaction.warehouseCode} / {transaction.locationCode}
|
||||||
|
</div>
|
||||||
|
{transaction.reference ? <div className="mt-2 text-xs text-muted">Ref: {transaction.reference}</div> : null}
|
||||||
|
{transaction.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted lg:text-right">
|
||||||
|
<div>{new Date(transaction.createdAt).toLocaleString()}</div>
|
||||||
|
<div className="mt-1">{transaction.createdByName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
|
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">From</span>
|
||||||
|
<select value={transferForm.fromLocationId} onChange={(event) => {
|
||||||
|
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
||||||
|
updateTransferField("fromLocationId", event.target.value);
|
||||||
|
if (option) {
|
||||||
|
updateTransferField("fromWarehouseId", option.warehouseId);
|
||||||
|
}
|
||||||
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{locationOptions.map((option) => (
|
||||||
|
<option key={`from-${option.locationId}`} value={option.locationId}>
|
||||||
|
{option.warehouseCode} / {option.locationCode}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">To</span>
|
||||||
|
<select value={transferForm.toLocationId} onChange={(event) => {
|
||||||
|
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
||||||
|
updateTransferField("toLocationId", event.target.value);
|
||||||
|
if (option) {
|
||||||
|
updateTransferField("toWarehouseId", option.warehouseId);
|
||||||
|
}
|
||||||
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{locationOptions.map((option) => (
|
||||||
|
<option key={`to-${option.locationId}`} value={option.locationId}>
|
||||||
|
{option.warehouseCode} / {option.locationCode}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={transferForm.notes} onChange={(event) => updateTransferField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
|
<span className="text-sm text-muted">{transferStatus}</span>
|
||||||
|
<button type="submit" disabled={isSavingTransfer} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSavingTransfer ? "Posting transfer..." : "Post transfer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
|
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
||||||
|
<select value={reservationForm.locationId ?? ""} onChange={(event) => {
|
||||||
|
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
||||||
|
setReservationForm((current) => ({
|
||||||
|
...current,
|
||||||
|
locationId: event.target.value || null,
|
||||||
|
warehouseId: option ? option.warehouseId : null,
|
||||||
|
}));
|
||||||
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
<option value="">Global / not location-specific</option>
|
||||||
|
{locationOptions.map((option) => (
|
||||||
|
<option key={option.locationId} value={option.locationId}>
|
||||||
|
{option.warehouseCode} / {option.locationCode}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
|
<span className="text-sm text-muted">{reservationStatus}</span>
|
||||||
|
<button type="submit" disabled={isSavingReservation} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSavingReservation ? "Saving reservation..." : "Create reservation"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Reservations</p>
|
||||||
|
{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">
|
||||||
|
No reservations have been recorded for this item.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{item.reservations.map((reservation) => (
|
||||||
|
<article key={reservation.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{reservation.quantity} reserved</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{reservation.sourceLabel ?? reservation.sourceType}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">{reservation.status}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">
|
||||||
|
{reservation.warehouseCode && reservation.locationCode ? `${reservation.warehouseCode} / ${reservation.locationCode}` : "Not location-specific"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-text">{reservation.notes || "No notes recorded."}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Transfers</p>
|
||||||
|
{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">
|
||||||
|
No transfers have been recorded for this item.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{item.transfers.map((transfer) => (
|
||||||
|
<article key={transfer.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-semibold text-text">{transfer.quantity} moved</div>
|
||||||
|
<div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">
|
||||||
|
{transfer.fromWarehouseCode} / {transfer.fromLocationCode} to {transfer.toWarehouseCode} / {transfer.toLocationCode}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-text">{transfer.notes || "No notes recorded."}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<InventoryAttachmentsPanel itemId={item.id} />
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm inventory action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={
|
||||||
|
(pendingConfirmation?.kind === "transaction" && isSavingTransaction) ||
|
||||||
|
(pendingConfirmation?.kind === "transfer" && isSavingTransfer) ||
|
||||||
|
(pendingConfirmation?.kind === "reservation" && isSavingReservation)
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isSavingTransaction && !isSavingTransfer && !isSavingReservation) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "transaction") {
|
||||||
|
await submitTransaction();
|
||||||
|
} else if (pendingConfirmation.kind === "transfer") {
|
||||||
|
await submitTransfer();
|
||||||
|
} else {
|
||||||
|
await submitReservation();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
1016
client/src/modules/inventory/InventoryFormPage.tsx
Normal file
1016
client/src/modules/inventory/InventoryFormPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InventoryListPage } from "./InventoryListPage";
|
||||||
|
|
||||||
|
export function InventoryItemsPage() {
|
||||||
|
return <InventoryListPage />;
|
||||||
|
}
|
||||||
153
client/src/modules/inventory/InventoryListPage.tsx
Normal file
153
client/src/modules/inventory/InventoryListPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { InventoryItemStatus, InventoryItemSummaryDto, InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { inventoryStatusFilters, inventoryTypeFilters } from "./config";
|
||||||
|
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
||||||
|
import { InventoryTypeBadge } from "./InventoryTypeBadge";
|
||||||
|
|
||||||
|
export function InventoryListPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [items, setItems] = useState<InventoryItemSummaryDto[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | InventoryItemStatus>("ALL");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<"ALL" | InventoryItemType>("ALL");
|
||||||
|
const [status, setStatus] = useState("Loading inventory items...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getInventoryItems(token, {
|
||||||
|
q: searchTerm.trim() || undefined,
|
||||||
|
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||||
|
type: typeFilter === "ALL" ? undefined : typeFilter,
|
||||||
|
})
|
||||||
|
.then((nextItems) => {
|
||||||
|
setItems(nextItems);
|
||||||
|
setStatus(`${nextItems.length} item(s) matched the current filters.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load inventory items.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [searchTerm, statusFilter, token, typeFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Item Master</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">
|
||||||
|
Core item and BOM definitions for purchased parts, manufactured items, assemblies, and service SKUs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link to="/inventory/sku-master" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
SKU master
|
||||||
|
</Link>
|
||||||
|
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
|
New item
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder="Search by SKU, item name, or description"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value as "ALL" | InventoryItemStatus)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{inventoryStatusFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Type</span>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(event) => setTypeFilter(event.target.value as "ALL" | InventoryItemType)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{inventoryTypeFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{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">
|
||||||
|
No inventory items have been added yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Item</th>
|
||||||
|
<th className="px-2 py-2">Type</th>
|
||||||
|
<th className="px-2 py-2">Status</th>
|
||||||
|
<th className="px-2 py-2">UOM</th>
|
||||||
|
<th className="px-2 py-2">Qty</th>
|
||||||
|
<th className="px-2 py-2">BOM</th>
|
||||||
|
<th className="px-2 py-2">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className="transition hover:bg-page/70">
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<Link to={`/inventory/items/${item.id}`} className="font-semibold text-text hover:text-brand">
|
||||||
|
{item.sku}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-xs text-muted">{item.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<InventoryTypeBadge type={item.type} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<InventoryStatusBadge status={item.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.unitOfMeasure}</td>
|
||||||
|
<td className="px-2 py-2 text-xs text-muted">
|
||||||
|
<div>Total {item.onHandQuantity}</div>
|
||||||
|
<div>Available {item.availableQuantity}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.bomLineCount} lines</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{new Date(item.updatedAt).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { InventorySkuCatalogTreeDto, InventorySkuFamilyInput, InventorySkuNodeDto, InventorySkuNodeInput } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
|
||||||
|
const emptyFamilyForm: InventorySkuFamilyInput = {
|
||||||
|
code: "",
|
||||||
|
sequenceCode: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyNodeForm: InventorySkuNodeInput = {
|
||||||
|
familyId: "",
|
||||||
|
parentNodeId: null,
|
||||||
|
code: "",
|
||||||
|
label: "",
|
||||||
|
description: "",
|
||||||
|
sortOrder: 10,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InventorySkuMasterPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [catalog, setCatalog] = useState<InventorySkuCatalogTreeDto>({ families: [], nodes: [] });
|
||||||
|
const [familyForm, setFamilyForm] = useState<InventorySkuFamilyInput>(emptyFamilyForm);
|
||||||
|
const [nodeForm, setNodeForm] = useState<InventorySkuNodeInput>(emptyNodeForm);
|
||||||
|
const [selectedFamilyId, setSelectedFamilyId] = useState("");
|
||||||
|
const [status, setStatus] = useState("Loading SKU master...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getInventorySkuCatalog(token)
|
||||||
|
.then((nextCatalog) => {
|
||||||
|
setCatalog(nextCatalog);
|
||||||
|
const firstFamilyId = nextCatalog.families[0]?.id ?? "";
|
||||||
|
setSelectedFamilyId((current) => current || firstFamilyId);
|
||||||
|
setNodeForm((current) => ({
|
||||||
|
...current,
|
||||||
|
familyId: current.familyId || firstFamilyId,
|
||||||
|
}));
|
||||||
|
setStatus(`${nextCatalog.families.length} family branch(es) loaded.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
setStatus(error instanceof ApiError ? error.message : "Unable to load SKU master.");
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const familyNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
catalog.nodes
|
||||||
|
.filter((node) => node.familyId === selectedFamilyId)
|
||||||
|
.sort((left, right) => left.level - right.level || left.sortOrder - right.sortOrder || left.code.localeCompare(right.code)),
|
||||||
|
[catalog.nodes, selectedFamilyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentOptions = useMemo(
|
||||||
|
() => familyNodes.filter((node) => node.level < 6),
|
||||||
|
[familyNodes]
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderNodes(parentNodeId: string | null, depth = 0): ReactNode {
|
||||||
|
const branchNodes = familyNodes.filter((node) => node.parentNodeId === parentNodeId);
|
||||||
|
if (!branchNodes.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return branchNodes.map((node) => (
|
||||||
|
<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="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text">{node.code} <span className="text-muted">- {node.label}</span></div>
|
||||||
|
<div className="mt-1 text-xs text-muted">Level {node.level} • {node.childCount} child branch(es)</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setNodeForm((current) => ({
|
||||||
|
...current,
|
||||||
|
familyId: selectedFamilyId,
|
||||||
|
parentNodeId: node.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text"
|
||||||
|
>
|
||||||
|
Add child
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{node.description ? <div className="mt-2 text-xs text-muted">{node.description}</div> : null}
|
||||||
|
</div>
|
||||||
|
{renderNodes(node.id, depth + 1)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadCatalog() {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCatalog = await api.getInventorySkuCatalog(token);
|
||||||
|
setCatalog(nextCatalog);
|
||||||
|
if (!selectedFamilyId && nextCatalog.families[0]) {
|
||||||
|
setSelectedFamilyId(nextCatalog.families[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateFamily(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token || !canManage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await api.createInventorySkuFamily(token, familyForm);
|
||||||
|
setFamilyForm(emptyFamilyForm);
|
||||||
|
setSelectedFamilyId(created.id);
|
||||||
|
setNodeForm((current) => ({ ...current, familyId: created.id, parentNodeId: null }));
|
||||||
|
await reloadCatalog();
|
||||||
|
setStatus(`Created SKU family ${created.code}.`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU family.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateNode(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token || !canManage || !nodeForm.familyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await api.createInventorySkuNode(token, nodeForm);
|
||||||
|
setNodeForm((current) => ({
|
||||||
|
...emptyNodeForm,
|
||||||
|
familyId: current.familyId,
|
||||||
|
parentNodeId: created.parentNodeId,
|
||||||
|
}));
|
||||||
|
await reloadCatalog();
|
||||||
|
setStatus(`Created SKU branch ${created.code}.`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU branch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Master Data</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">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>
|
||||||
|
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Back to items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.5fr]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="text-sm font-semibold text-text">Families</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
catalog.families.map((family) => (
|
||||||
|
<button
|
||||||
|
key={family.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFamilyId(family.id);
|
||||||
|
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
|
||||||
|
}}
|
||||||
|
className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${
|
||||||
|
selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold text-text">{family.code} <span className="text-muted">({family.sequenceCode})</span></div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{family.name}</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">{family.childNodeCount} branch nodes • next {family.sequenceCode}{String(family.nextSequenceNumber).padStart(4, "0")}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="text-sm font-semibold text-text">Add family</div>
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={handleCreateFamily}>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
|
||||||
|
<input value={familyForm.code} onChange={(event) => setFamilyForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Suffix code</span>
|
||||||
|
<input value={familyForm.sequenceCode} onChange={(event) => setFamilyForm((current) => ({ ...current, sequenceCode: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
|
||||||
|
<input value={familyForm.name} onChange={(event) => setFamilyForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||||
|
<textarea value={familyForm.description} onChange={(event) => setFamilyForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">Create family</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text">Branch tree</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{status}</div>
|
||||||
|
</div>
|
||||||
|
{selectedFamilyId ? (
|
||||||
|
<div className="text-xs text-muted">Up to 6 total SKU levels including family root.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{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>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="text-sm font-semibold text-text">Add branch node</div>
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={handleCreateNode}>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
|
||||||
|
<select value={nodeForm.familyId} onChange={(event) => setNodeForm((current) => ({ ...current, familyId: event.target.value, parentNodeId: null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
<option value="">Select family</option>
|
||||||
|
{catalog.families.map((family) => (
|
||||||
|
<option key={family.id} value={family.id}>{family.code} - {family.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Parent branch</span>
|
||||||
|
<select value={nodeForm.parentNodeId ?? ""} onChange={(event) => setNodeForm((current) => ({ ...current, parentNodeId: event.target.value || null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
<option value="">Family root</option>
|
||||||
|
{parentOptions.map((node) => (
|
||||||
|
<option key={node.id} value={node.id}>L{node.level} - {node.code} - {node.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
||||||
|
<input value={nodeForm.code} onChange={(event) => setNodeForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Label</span>
|
||||||
|
<input value={nodeForm.label} onChange={(event) => setNodeForm((current) => ({ ...current, label: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||||
|
<textarea value={nodeForm.description} onChange={(event) => setNodeForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sort</span>
|
||||||
|
<input type="number" min={0} step={10} value={nodeForm.sortOrder} onChange={(event) => setNodeForm((current) => ({ ...current, sortOrder: Number(event.target.value) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" disabled={!nodeForm.familyId}>Create branch</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal file
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { InventoryItemStatus } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
|
||||||
|
import { inventoryStatusPalette } from "./config";
|
||||||
|
|
||||||
|
const labels: Record<InventoryItemStatus, string> = {
|
||||||
|
DRAFT: "Draft",
|
||||||
|
ACTIVE: "Active",
|
||||||
|
OBSOLETE: "Obsolete",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InventoryStatusBadge({ status }: { status: InventoryItemStatus }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryStatusPalette[status]}`}>
|
||||||
|
{labels[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { InventoryTransactionType } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
|
||||||
|
import { inventoryTransactionOptions, inventoryTransactionPalette } from "./config";
|
||||||
|
|
||||||
|
export function InventoryTransactionTypeBadge({ type }: { type: InventoryTransactionType }) {
|
||||||
|
const label = inventoryTransactionOptions.find((option) => option.value === type)?.label ?? type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${inventoryTransactionPalette[type]}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal file
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
|
||||||
|
import { inventoryTypePalette } from "./config";
|
||||||
|
|
||||||
|
const labels: Record<InventoryItemType, string> = {
|
||||||
|
PURCHASED: "Purchased",
|
||||||
|
MANUFACTURED: "Manufactured",
|
||||||
|
ASSEMBLY: "Assembly",
|
||||||
|
SERVICE: "Service",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InventoryTypeBadge({ type }: { type: InventoryItemType }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryTypePalette[type]}`}>
|
||||||
|
{labels[type]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
client/src/modules/inventory/WarehouseDetailPage.tsx
Normal file
91
client/src/modules/inventory/WarehouseDetailPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { WarehouseDetailDto, WarehouseLocationDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
|
||||||
|
export function WarehouseDetailPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { warehouseId } = useParams();
|
||||||
|
const [warehouse, setWarehouse] = useState<WarehouseDetailDto | null>(null);
|
||||||
|
const [status, setStatus] = useState("Loading warehouse...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !warehouseId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getWarehouse(token, warehouseId)
|
||||||
|
.then((nextWarehouse) => {
|
||||||
|
setWarehouse(nextWarehouse);
|
||||||
|
setStatus("Warehouse loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load warehouse.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [token, warehouseId]);
|
||||||
|
|
||||||
|
if (!warehouse) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Detail</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-bold text-text">{warehouse.code}</h3>
|
||||||
|
<p className="mt-1 text-sm text-text">{warehouse.name}</p>
|
||||||
|
<p className="mt-3 text-sm text-muted">Last updated {new Date(warehouse.updatedAt).toLocaleString()}.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/inventory/warehouses" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Back to warehouses
|
||||||
|
</Link>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`/inventory/warehouses/${warehouse.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
|
Edit warehouse
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{warehouse.notes || "No warehouse notes recorded."}</p>
|
||||||
|
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||||
|
Created {new Date(warehouse.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Stock locations</h4>
|
||||||
|
{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">
|
||||||
|
No stock locations have been defined for this warehouse yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 grid gap-3 xl:grid-cols-2">
|
||||||
|
{warehouse.locations.map((location: WarehouseLocationDto) => (
|
||||||
|
<article key={location.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
|
<div className="text-sm font-semibold text-text">{location.code}</div>
|
||||||
|
<div className="mt-1 text-sm text-text">{location.name}</div>
|
||||||
|
<div className="mt-2 text-xs leading-6 text-muted">{location.notes || "No notes."}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
192
client/src/modules/inventory/WarehouseFormPage.tsx
Normal file
192
client/src/modules/inventory/WarehouseFormPage.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type { WarehouseInput, WarehouseLocationInput } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config";
|
||||||
|
|
||||||
|
export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { warehouseId } = useParams();
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [form, setForm] = useState<WarehouseInput>(emptyWarehouseInput);
|
||||||
|
const [status, setStatus] = useState(mode === "create" ? "Create a new warehouse." : "Loading warehouse...");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [pendingLocationRemovalIndex, setPendingLocationRemovalIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "edit" || !token || !warehouseId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getWarehouse(token, warehouseId)
|
||||||
|
.then((warehouse) => {
|
||||||
|
setForm({
|
||||||
|
code: warehouse.code,
|
||||||
|
name: warehouse.name,
|
||||||
|
notes: warehouse.notes,
|
||||||
|
locations: warehouse.locations.map((location: WarehouseLocationInput) => ({
|
||||||
|
code: location.code,
|
||||||
|
name: location.name,
|
||||||
|
notes: location.notes,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setStatus("Warehouse loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load warehouse.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [mode, token, warehouseId]);
|
||||||
|
|
||||||
|
function updateField<Key extends keyof WarehouseInput>(key: Key, value: WarehouseInput[Key]) {
|
||||||
|
setForm((current: WarehouseInput) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLocation(index: number, nextLocation: WarehouseLocationInput) {
|
||||||
|
setForm((current: WarehouseInput) => ({
|
||||||
|
...current,
|
||||||
|
locations: current.locations.map((location: WarehouseLocationInput, locationIndex: number) =>
|
||||||
|
locationIndex === index ? nextLocation : location
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLocation() {
|
||||||
|
setForm((current: WarehouseInput) => ({
|
||||||
|
...current,
|
||||||
|
locations: [...current.locations, emptyWarehouseLocationInput],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLocation(index: number) {
|
||||||
|
setForm((current: WarehouseInput) => ({
|
||||||
|
...current,
|
||||||
|
locations: current.locations.filter((_location: WarehouseLocationInput, locationIndex: number) => locationIndex !== index),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingLocationRemoval = pendingLocationRemovalIndex != null ? form.locations[pendingLocationRemovalIndex] : null;
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Saving warehouse...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved =
|
||||||
|
mode === "create" ? await api.createWarehouse(token, form) : await api.updateWarehouse(token, warehouseId ?? "", form);
|
||||||
|
navigate(`/inventory/warehouses/${saved.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save warehouse.";
|
||||||
|
setStatus(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Editor</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Warehouse" : "Edit Warehouse"}</h3>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={mode === "create" ? "/inventory/warehouses" : `/inventory/warehouses/${warehouseId}`}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Warehouse code</span>
|
||||||
|
<input value={form.code} onChange={(event) => updateField("code", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Warehouse name</span>
|
||||||
|
<input value={form.name} onChange={(event) => updateField("name", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Internal stock locations</h4>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={addLocation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Add location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.locations.length === 0 ? (
|
||||||
|
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||||
|
No locations added yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{form.locations.map((location: WarehouseLocationInput, index: number) => (
|
||||||
|
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[0.7fr_1fr_auto]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
||||||
|
<input value={location.code} onChange={(event) => updateLocation(index, { ...location, code: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
|
||||||
|
<input value={location.name} onChange={(event) => updateLocation(index, { ...location, name: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button type="button" onClick={() => setPendingLocationRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||||
|
<input value={location.notes} onChange={(event) => updateLocation(index, { ...location, notes: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-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">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving..." : mode === "create" ? "Create warehouse" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingLocationRemoval != null}
|
||||||
|
title="Remove warehouse location"
|
||||||
|
description={pendingLocationRemoval ? `Remove location ${pendingLocationRemoval.code || pendingLocationRemoval.name || "from this warehouse draft"}.` : "Remove this location."}
|
||||||
|
impact="The location will be removed from the warehouse edit form immediately."
|
||||||
|
recovery="Add the location back before saving if it should remain part of this warehouse."
|
||||||
|
confirmLabel="Remove location"
|
||||||
|
onClose={() => setPendingLocationRemovalIndex(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingLocationRemovalIndex != null) {
|
||||||
|
removeLocation(pendingLocationRemovalIndex);
|
||||||
|
}
|
||||||
|
setPendingLocationRemovalIndex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
83
client/src/modules/inventory/WarehousesPage.tsx
Normal file
83
client/src/modules/inventory/WarehousesPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { WarehouseSummaryDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
|
||||||
|
export function WarehousesPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [warehouses, setWarehouses] = useState<WarehouseSummaryDto[]>([]);
|
||||||
|
const [status, setStatus] = useState("Loading warehouses...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getWarehouses(token)
|
||||||
|
.then((nextWarehouses) => {
|
||||||
|
setWarehouses(nextWarehouses);
|
||||||
|
setStatus(`${nextWarehouses.length} warehouse(s) available.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load warehouses.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Warehouses</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">Physical warehouse records and their internal stock locations.</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to="/inventory/warehouses/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
|
New warehouse
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{warehouses.length === 0 ? (
|
||||||
|
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||||
|
No warehouses have been added yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Code</th>
|
||||||
|
<th className="px-2 py-2">Name</th>
|
||||||
|
<th className="px-2 py-2">Locations</th>
|
||||||
|
<th className="px-2 py-2">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{warehouses.map((warehouse) => (
|
||||||
|
<tr key={warehouse.id} className="transition hover:bg-page/70">
|
||||||
|
<td className="px-2 py-2 font-semibold text-text">
|
||||||
|
<Link to={`/inventory/warehouses/${warehouse.id}`} className="hover:text-brand">
|
||||||
|
{warehouse.code}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{warehouse.name}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{warehouse.locationCount}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{new Date(warehouse.updatedAt).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
133
client/src/modules/inventory/config.ts
Normal file
133
client/src/modules/inventory/config.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
inventoryItemStatuses,
|
||||||
|
inventoryItemTypes,
|
||||||
|
inventoryTransactionTypes,
|
||||||
|
inventoryUnitsOfMeasure,
|
||||||
|
type InventoryBomLineInput,
|
||||||
|
type InventoryItemInput,
|
||||||
|
type InventoryItemOperationInput,
|
||||||
|
type WarehouseInput,
|
||||||
|
type WarehouseLocationInput,
|
||||||
|
type InventoryItemStatus,
|
||||||
|
type InventoryItemType,
|
||||||
|
type InventoryTransactionInput,
|
||||||
|
type InventoryTransactionType,
|
||||||
|
type InventoryUnitOfMeasure,
|
||||||
|
} from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
|
||||||
|
export const emptyInventoryBomLineInput: InventoryBomLineInput = {
|
||||||
|
componentItemId: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitOfMeasure: "EA",
|
||||||
|
notes: "",
|
||||||
|
position: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyInventoryOperationInput: InventoryItemOperationInput = {
|
||||||
|
stationId: "",
|
||||||
|
setupMinutes: 0,
|
||||||
|
runMinutesPerUnit: 0,
|
||||||
|
moveMinutes: 0,
|
||||||
|
position: 10,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyInventoryItemInput: InventoryItemInput = {
|
||||||
|
sku: "",
|
||||||
|
skuBuilder: null,
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
type: "PURCHASED",
|
||||||
|
status: "ACTIVE",
|
||||||
|
unitOfMeasure: "EA",
|
||||||
|
isSellable: true,
|
||||||
|
isPurchasable: true,
|
||||||
|
preferredVendorId: null,
|
||||||
|
defaultCost: null,
|
||||||
|
defaultPrice: null,
|
||||||
|
notes: "",
|
||||||
|
bomLines: [],
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyInventoryTransactionInput: InventoryTransactionInput = {
|
||||||
|
transactionType: "RECEIPT",
|
||||||
|
quantity: 1,
|
||||||
|
warehouseId: "",
|
||||||
|
locationId: "",
|
||||||
|
reference: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyWarehouseLocationInput: WarehouseLocationInput = {
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyWarehouseInput: WarehouseInput = {
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
notes: "",
|
||||||
|
locations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inventoryTypeOptions: Array<{ value: InventoryItemType; label: string }> = [
|
||||||
|
{ value: "PURCHASED", label: "Purchased" },
|
||||||
|
{ value: "MANUFACTURED", label: "Manufactured" },
|
||||||
|
{ value: "ASSEMBLY", label: "Assembly" },
|
||||||
|
{ value: "SERVICE", label: "Service" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const inventoryStatusOptions: Array<{ value: InventoryItemStatus; label: string }> = [
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "OBSOLETE", label: "Obsolete" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const inventoryStatusFilters: Array<{ value: "ALL" | InventoryItemStatus; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All statuses" },
|
||||||
|
...inventoryStatusOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const inventoryTypeFilters: Array<{ value: "ALL" | InventoryItemType; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All item types" },
|
||||||
|
...inventoryTypeOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const inventoryUnitOptions: Array<{ value: InventoryUnitOfMeasure; label: string }> = inventoryUnitsOfMeasure.map((unit) => ({
|
||||||
|
value: unit,
|
||||||
|
label: unit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
|
||||||
|
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
OBSOLETE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inventoryFileOwnerType = "inventory-item";
|
||||||
|
export const inventoryThumbnailOwnerType = "inventory-item-thumbnail";
|
||||||
|
|
||||||
|
export const inventoryTypePalette: Record<InventoryItemType, string> = {
|
||||||
|
PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
MANUFACTURED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
ASSEMBLY: "border border-brand/30 bg-brand/10 text-brand",
|
||||||
|
SERVICE: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inventoryTransactionOptions: Array<{ value: InventoryTransactionType; label: string }> = [
|
||||||
|
{ value: "RECEIPT", label: "Receipt" },
|
||||||
|
{ value: "ISSUE", label: "Issue" },
|
||||||
|
{ value: "ADJUSTMENT_IN", label: "Adjustment In" },
|
||||||
|
{ value: "ADJUSTMENT_OUT", label: "Adjustment Out" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const inventoryTransactionPalette: Record<InventoryTransactionType, string> = {
|
||||||
|
RECEIPT: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
ISSUE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||||
|
ADJUSTMENT_IN: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
ADJUSTMENT_OUT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure };
|
||||||
75
client/src/modules/login/LoginPage.tsx
Normal file
75
client/src/modules/login/LoginPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login, token } = useAuth();
|
||||||
|
const [email, setEmail] = useState("admin@mrp.local");
|
||||||
|
const [password, setPassword] = useState("ChangeMe123!");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
} catch (submissionError) {
|
||||||
|
setError(submissionError instanceof Error ? submissionError.message : "Unable to sign in.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||||
|
<div className="grid w-full max-w-5xl overflow-hidden rounded-[32px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<section className="bg-brand px-6 py-10 text-white md:px-10">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-white/75">MRP Codex</p>
|
||||||
|
<h1 className="mt-6 text-4xl font-extrabold">A streamlined manufacturing operating system.</h1>
|
||||||
|
<p className="mt-4 max-w-xl text-sm leading-6 text-white/82">
|
||||||
|
This foundation release establishes authentication, company settings, brand theming, file persistence, and planning scaffolding.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section className="px-6 py-10 md:px-10">
|
||||||
|
<h2 className="text-lg font-bold text-text">Sign in</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted">Use the seeded admin account to access the initial platform shell.</p>
|
||||||
|
<form className="mt-8 space-y-5" onSubmit={handleSubmit}>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
||||||
|
<input
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(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>
|
||||||
|
{error ? <div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-2 py-2 text-sm text-red-200 dark:text-red-200">{error}</div> : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full rounded-2xl bg-text px-2 py-2 text-sm font-semibold text-page transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Signing in..." : "Enter workspace"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
client/src/modules/manufacturing/ManufacturingPage.tsx
Normal file
121
client/src/modules/manufacturing/ManufacturingPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { permissions, type ManufacturingStationInput, type ManufacturingStationDto } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { WorkOrderListPage } from "./WorkOrderListPage";
|
||||||
|
|
||||||
|
const emptyStationInput: ManufacturingStationInput = {
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
queueDays: 0,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ManufacturingPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
||||||
|
const [form, setForm] = useState<ManufacturingStationInput>(emptyStationInput);
|
||||||
|
const [status, setStatus] = useState("Define manufacturing stations once so routings and work orders can schedule automatically.");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Saving station...");
|
||||||
|
try {
|
||||||
|
const station = await api.createManufacturingStation(token, form);
|
||||||
|
setStations((current) => [...current, station].sort((left, right) => left.code.localeCompare(right.code)));
|
||||||
|
setForm(emptyStationInput);
|
||||||
|
setStatus("Station saved.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save station.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Stations</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">Scheduling anchors</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted">Stations define where operation time belongs. Buildable items reference them in their routing template, and work orders inherit those steps automatically into planning.</p>
|
||||||
|
{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">
|
||||||
|
No stations defined yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{stations.map((station) => (
|
||||||
|
<article key={station.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{station.code} - {station.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{station.description || "No description"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted">
|
||||||
|
<div>{station.queueDays} expected wait day(s)</div>
|
||||||
|
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
{canManage ? (
|
||||||
|
<form onSubmit={handleSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">New Station</p>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Code</span>
|
||||||
|
<input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Name</span>
|
||||||
|
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Expected Wait (Days)</span>
|
||||||
|
<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 className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
||||||
|
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
|
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} />
|
||||||
|
<span className="text-sm font-semibold text-text">Active station</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
|
<span className="text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving..." : "Create station"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
<WorkOrderListPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
487
client/src/modules/manufacturing/WorkOrderDetailPage.tsx
Normal file
487
client/src/modules/manufacturing/WorkOrderDetailPage.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
|
||||||
|
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
|
||||||
|
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
|
||||||
|
|
||||||
|
export function WorkOrderDetailPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { workOrderId } = useParams();
|
||||||
|
const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null);
|
||||||
|
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||||
|
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
|
||||||
|
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
|
||||||
|
const [status, setStatus] = useState("Loading work order...");
|
||||||
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
|
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||||
|
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "status" | "issue" | "completion";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
nextStatus?: WorkOrderStatus;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !workOrderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getWorkOrder(token, workOrderId)
|
||||||
|
.then((nextWorkOrder) => {
|
||||||
|
setWorkOrder(nextWorkOrder);
|
||||||
|
setIssueForm({
|
||||||
|
...emptyMaterialIssueInput,
|
||||||
|
warehouseId: nextWorkOrder.warehouseId,
|
||||||
|
locationId: nextWorkOrder.locationId,
|
||||||
|
});
|
||||||
|
setCompletionForm({
|
||||||
|
...emptyCompletionInput,
|
||||||
|
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||||
|
});
|
||||||
|
setStatus("Work order loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
|
||||||
|
}, [token, workOrderId]);
|
||||||
|
|
||||||
|
const filteredLocationOptions = useMemo(
|
||||||
|
() => locationOptions.filter((option) => option.warehouseId === issueForm.warehouseId),
|
||||||
|
[issueForm.warehouseId, locationOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function applyStatusChange(nextStatus: WorkOrderStatus) {
|
||||||
|
if (!token || !workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingStatus(true);
|
||||||
|
setStatus("Updating work-order status...");
|
||||||
|
try {
|
||||||
|
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
|
||||||
|
setWorkOrder(nextWorkOrder);
|
||||||
|
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitIssue() {
|
||||||
|
if (!token || !workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPostingIssue(true);
|
||||||
|
setStatus("Posting material issue...");
|
||||||
|
try {
|
||||||
|
const nextWorkOrder = await api.issueWorkOrderMaterial(token, workOrder.id, issueForm);
|
||||||
|
setWorkOrder(nextWorkOrder);
|
||||||
|
setIssueForm({
|
||||||
|
...emptyMaterialIssueInput,
|
||||||
|
warehouseId: nextWorkOrder.warehouseId,
|
||||||
|
locationId: nextWorkOrder.locationId,
|
||||||
|
});
|
||||||
|
setStatus("Material issue posted. This consumed inventory immediately; post a correcting stock movement if the issue quantity was wrong.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsPostingIssue(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCompletion() {
|
||||||
|
if (!token || !workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPostingCompletion(true);
|
||||||
|
setStatus("Posting completion...");
|
||||||
|
try {
|
||||||
|
const nextWorkOrder = await api.recordWorkOrderCompletion(token, workOrder.id, completionForm);
|
||||||
|
setWorkOrder(nextWorkOrder);
|
||||||
|
setCompletionForm({
|
||||||
|
...emptyCompletionInput,
|
||||||
|
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||||
|
});
|
||||||
|
setStatus("Completion posted. Finished-goods stock has been received; verify the remaining quantity and post a correcting transaction if this completion was overstated.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsPostingCompletion(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||||
|
if (!workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = workOrderStatusOptions.find((entry) => entry.value === nextStatus);
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "status",
|
||||||
|
title: `Change status to ${option?.label ?? nextStatus}`,
|
||||||
|
description: `Update work order ${workOrder.workOrderNumber} from ${workOrder.status} to ${nextStatus}.`,
|
||||||
|
impact:
|
||||||
|
nextStatus === "CANCELLED"
|
||||||
|
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
||||||
|
: nextStatus === "COMPLETE"
|
||||||
|
? "Completing the work order signals execution closure and can change readiness views across the system."
|
||||||
|
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
||||||
|
recovery: "If this status was selected in error, set the work order back to the correct state immediately after review.",
|
||||||
|
confirmLabel: `Set ${option?.label ?? nextStatus}`,
|
||||||
|
confirmationLabel: nextStatus === "CANCELLED" ? "Type work-order number to confirm:" : undefined,
|
||||||
|
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
||||||
|
nextStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const component = workOrder.materialRequirements.find((requirement) => requirement.componentItemId === issueForm.componentItemId);
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "issue",
|
||||||
|
title: "Post material issue",
|
||||||
|
description: `Issue ${issueForm.quantity} units of ${component?.componentSku ?? "the selected component"} to work order ${workOrder.workOrderNumber}.`,
|
||||||
|
impact: "This consumes component inventory immediately and updates work-order material history.",
|
||||||
|
recovery: "If the wrong quantity was issued, post a correcting stock transaction and note the reason on the work order.",
|
||||||
|
confirmLabel: "Post issue",
|
||||||
|
confirmationLabel: "Type work-order number to confirm:",
|
||||||
|
confirmationValue: workOrder.workOrderNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!workOrder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "completion",
|
||||||
|
title: "Post production completion",
|
||||||
|
description: `Receive ${completionForm.quantity} finished units into ${workOrder.warehouseCode} / ${workOrder.locationCode}.`,
|
||||||
|
impact: "This increases finished-goods inventory immediately and advances the execution history for this work order.",
|
||||||
|
recovery: "If the completion quantity is wrong, post the correcting inventory movement and verify the work-order remaining quantity.",
|
||||||
|
confirmLabel: "Post completion",
|
||||||
|
confirmationLabel: completionForm.quantity >= workOrder.dueQuantity ? "Type work-order number to confirm:" : undefined,
|
||||||
|
confirmationValue: completionForm.quantity >= workOrder.dueQuantity ? workOrder.workOrderNumber : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workOrder) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Order</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3>
|
||||||
|
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
|
||||||
|
<div className="mt-3"><WorkOrderStatusBadge status={workOrder.status} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
|
||||||
|
{workOrder.projectId ? <Link to={`/projects/${workOrder.projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open project</Link> : null}
|
||||||
|
{workOrder.salesOrderId ? <Link to={`/sales/orders/${workOrder.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open sales order</Link> : null}
|
||||||
|
<Link to={`/inventory/items/${workOrder.itemId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open item</Link>
|
||||||
|
{canManage ? <Link to={`/manufacturing/work-orders/${workOrder.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit work order</Link> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Release, hold, or close administrative status from the work-order record.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{workOrderStatusOptions.map((option) => (
|
||||||
|
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || workOrder.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="grid gap-3 xl:grid-cols-6">
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">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="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="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="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="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="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>
|
||||||
|
</section>
|
||||||
|
<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">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Execution Context</p>
|
||||||
|
<dl className="mt-5 grid gap-3">
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project customer</dt><dd className="mt-1 text-sm text-text">{workOrder.projectCustomerName || "Not linked"}</dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Instructions</p>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Operation Plan</p>
|
||||||
|
{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-5 overflow-hidden rounded-[18px] border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/70">
|
||||||
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
<th className="px-3 py-3">Seq</th>
|
||||||
|
<th className="px-3 py-3">Station</th>
|
||||||
|
<th className="px-3 py-3">Start</th>
|
||||||
|
<th className="px-3 py-3">End</th>
|
||||||
|
<th className="px-3 py-3">Minutes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70">
|
||||||
|
{workOrder.operations.map((operation) => (
|
||||||
|
<tr key={operation.id} className="bg-surface/70">
|
||||||
|
<td className="px-3 py-3 text-text">{operation.sequence}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="font-semibold text-text">{operation.stationCode}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{operation.plannedMinutes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{canManage ? (
|
||||||
|
<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">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Issue</p>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Component</span>
|
||||||
|
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
<option value="">Select component</option>
|
||||||
|
{workOrder.materialRequirements.map((requirement) => (
|
||||||
|
<option key={requirement.componentItemId} value={requirement.componentItemId}>{requirement.componentSku} - {requirement.componentName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
|
||||||
|
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
|
||||||
|
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
||||||
|
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
<option value="">Select location</option>
|
||||||
|
{filteredLocationOptions.map((option) => (
|
||||||
|
<option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
|
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isPostingIssue ? "Posting issue..." : "Post material issue"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form onSubmit={handleCompletionSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Production Completion</p>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
|
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
|
||||||
|
<button type="submit" disabled={isPostingCompletion} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isPostingCompletion ? "Posting completion..." : "Post completion"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Requirements</p>
|
||||||
|
{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-5 overflow-hidden rounded-[18px] border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/70">
|
||||||
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
<th className="px-3 py-3">Component</th>
|
||||||
|
<th className="px-3 py-3">Per</th>
|
||||||
|
<th className="px-3 py-3">Required</th>
|
||||||
|
<th className="px-3 py-3">Issued</th>
|
||||||
|
<th className="px-3 py-3">Remaining</th>
|
||||||
|
<th className="px-3 py-3">Available</th>
|
||||||
|
<th className="px-3 py-3">Shortage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70">
|
||||||
|
{workOrder.materialRequirements.map((requirement) => (
|
||||||
|
<tr key={requirement.componentItemId} className="bg-surface/70">
|
||||||
|
<td className="px-3 py-3"><div className="font-semibold text-text">{requirement.componentSku}</div><div className="mt-1 text-xs text-muted">{requirement.componentName}</div></td>
|
||||||
|
<td className="px-3 py-3 text-text">{requirement.quantityPer} {requirement.unitOfMeasure}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{requirement.requiredQuantity}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{requirement.issuedQuantity}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{requirement.availableQuantity}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{requirement.shortageQuantity}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Issue History</p>
|
||||||
|
{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-5 space-y-3">
|
||||||
|
{workOrder.materialIssues.map((issue) => (
|
||||||
|
<div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{issue.warehouseCode} / {issue.locationCode} · {issue.createdByName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-text">{issue.quantity}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">{new Date(issue.createdAt).toLocaleString()}</div>
|
||||||
|
<div className="mt-2 text-sm text-text">{issue.notes || "No notes recorded."}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Completion History</p>
|
||||||
|
{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-5 space-y-3">
|
||||||
|
{workOrder.completions.map((completion) => (
|
||||||
|
<div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="font-semibold text-text">{completion.quantity} completed</div>
|
||||||
|
<div className="text-xs text-muted">{completion.createdByName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">{new Date(completion.createdAt).toLocaleString()}</div>
|
||||||
|
<div className="mt-2 text-sm text-text">{completion.notes || "No notes recorded."}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<FileAttachmentsPanel
|
||||||
|
ownerType="WORK_ORDER"
|
||||||
|
ownerId={workOrder.id}
|
||||||
|
eyebrow="Manufacturing Documents"
|
||||||
|
title="Work-order files"
|
||||||
|
description="Store travelers, build instructions, inspection records, and support documents directly on the work order."
|
||||||
|
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
|
||||||
|
/>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm manufacturing action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={
|
||||||
|
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||||
|
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
||||||
|
(pendingConfirmation?.kind === "completion" && isPostingCompletion)
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isUpdatingStatus && !isPostingIssue && !isPostingCompletion) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||||
|
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||||
|
} else if (pendingConfirmation.kind === "issue") {
|
||||||
|
await submitIssue();
|
||||||
|
} else if (pendingConfirmation.kind === "completion") {
|
||||||
|
await submitCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
307
client/src/modules/manufacturing/WorkOrderFormPage.tsx
Normal file
307
client/src/modules/manufacturing/WorkOrderFormPage.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import type {
|
||||||
|
ManufacturingItemOptionDto,
|
||||||
|
ManufacturingProjectOptionDto,
|
||||||
|
WorkOrderInput,
|
||||||
|
} from "@mrp/shared";
|
||||||
|
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { emptyWorkOrderInput, workOrderStatusOptions } from "./config";
|
||||||
|
|
||||||
|
export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { workOrderId } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const seededProjectId = searchParams.get("projectId");
|
||||||
|
const seededItemId = searchParams.get("itemId");
|
||||||
|
const seededSalesOrderId = searchParams.get("salesOrderId");
|
||||||
|
const seededSalesOrderLineId = searchParams.get("salesOrderLineId");
|
||||||
|
const seededQuantity = searchParams.get("quantity");
|
||||||
|
const seededStatus = searchParams.get("status");
|
||||||
|
const seededDueDate = searchParams.get("dueDate");
|
||||||
|
const seededNotes = searchParams.get("notes");
|
||||||
|
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
|
||||||
|
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
|
||||||
|
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
|
||||||
|
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||||
|
const [itemSearchTerm, setItemSearchTerm] = useState("");
|
||||||
|
const [projectSearchTerm, setProjectSearchTerm] = useState("");
|
||||||
|
const [itemPickerOpen, setItemPickerOpen] = useState(false);
|
||||||
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
||||||
|
const [status, setStatus] = useState(mode === "create" ? "Create a new work order." : "Loading work order...");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getManufacturingItemOptions(token).then((options) => {
|
||||||
|
setItemOptions(options);
|
||||||
|
if (mode === "create" && seededItemId) {
|
||||||
|
const seededItem = options.find((option) => option.id === seededItemId);
|
||||||
|
if (seededItem) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
itemId: seededItem.id,
|
||||||
|
salesOrderId: seededSalesOrderId || current.salesOrderId,
|
||||||
|
salesOrderLineId: seededSalesOrderLineId || current.salesOrderLineId,
|
||||||
|
quantity: seededQuantity ? Number.parseInt(seededQuantity, 10) || current.quantity : current.quantity,
|
||||||
|
status: seededStatus && workOrderStatusOptions.some((option) => option.value === seededStatus) ? (seededStatus as WorkOrderInput["status"]) : current.status,
|
||||||
|
dueDate: seededDueDate || current.dueDate,
|
||||||
|
notes: seededNotes || current.notes,
|
||||||
|
}));
|
||||||
|
setItemSearchTerm(`${seededItem.sku} - ${seededItem.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => setItemOptions([]));
|
||||||
|
api.getManufacturingProjectOptions(token).then((options) => {
|
||||||
|
setProjectOptions(options);
|
||||||
|
if (mode === "create" && seededProjectId) {
|
||||||
|
const seededProject = options.find((option) => option.id === seededProjectId);
|
||||||
|
if (seededProject) {
|
||||||
|
setForm((current) => ({ ...current, projectId: seededProject.id }));
|
||||||
|
setProjectSearchTerm(`${seededProject.projectNumber} - ${seededProject.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => setProjectOptions([]));
|
||||||
|
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
|
||||||
|
}, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || mode !== "edit" || !workOrderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getWorkOrder(token, workOrderId)
|
||||||
|
.then((workOrder) => {
|
||||||
|
setForm({
|
||||||
|
itemId: workOrder.itemId,
|
||||||
|
projectId: workOrder.projectId,
|
||||||
|
salesOrderId: workOrder.salesOrderId,
|
||||||
|
salesOrderLineId: workOrder.salesOrderLineId,
|
||||||
|
status: workOrder.status,
|
||||||
|
quantity: workOrder.quantity,
|
||||||
|
warehouseId: workOrder.warehouseId,
|
||||||
|
locationId: workOrder.locationId,
|
||||||
|
dueDate: workOrder.dueDate,
|
||||||
|
notes: workOrder.notes,
|
||||||
|
});
|
||||||
|
setItemSearchTerm(`${workOrder.itemSku} - ${workOrder.itemName}`);
|
||||||
|
setProjectSearchTerm(workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "");
|
||||||
|
setStatus("Work order loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [mode, token, workOrderId]);
|
||||||
|
|
||||||
|
const warehouseOptions = useMemo(
|
||||||
|
() => [...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()],
|
||||||
|
[locationOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredLocationOptions = useMemo(
|
||||||
|
() => locationOptions.filter((option) => option.warehouseId === form.warehouseId),
|
||||||
|
[form.warehouseId, locationOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateField<Key extends keyof WorkOrderInput>(key: Key, value: WorkOrderInput[Key]) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: value,
|
||||||
|
...(key === "warehouseId" ? { locationId: "" } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Saving work order...");
|
||||||
|
try {
|
||||||
|
const saved = mode === "create" ? await api.createWorkOrder(token, form) : await api.updateWorkOrder(token, workOrderId ?? "", form);
|
||||||
|
navigate(`/manufacturing/work-orders/${saved.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save work order.";
|
||||||
|
setStatus(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Editor</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Work Order" : "Edit Work Order"}</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">Create a build record for a manufactured item, assign it to a project when needed, and define where completed output should post.</p>
|
||||||
|
</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">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={itemSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setItemSearchTerm(event.target.value);
|
||||||
|
updateField("itemId", "");
|
||||||
|
setItemPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setItemPickerOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setItemPickerOpen(false);
|
||||||
|
const selected = itemOptions.find((option) => option.id === form.itemId);
|
||||||
|
if (selected) {
|
||||||
|
setItemSearchTerm(`${selected.sku} - ${selected.name}`);
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
placeholder="Search manufactured item"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{itemPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
{itemOptions
|
||||||
|
.filter((option) => {
|
||||||
|
const query = itemSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return option.sku.toLowerCase().includes(query) || option.name.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((option) => (
|
||||||
|
<button key={option.id} type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("itemId", option.id);
|
||||||
|
setItemSearchTerm(`${option.sku} - ${option.name}`);
|
||||||
|
setItemPickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">{option.sku}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{option.name} · {option.type} · {option.operationCount} ops</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Project</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={projectSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setProjectSearchTerm(event.target.value);
|
||||||
|
updateField("projectId", null);
|
||||||
|
setProjectPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setProjectPickerOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setProjectPickerOpen(false);
|
||||||
|
const selected = projectOptions.find((option) => option.id === form.projectId);
|
||||||
|
if (selected) {
|
||||||
|
setProjectSearchTerm(`${selected.projectNumber} - ${selected.name}`);
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
placeholder="Search linked project (optional)"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{projectPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
<button type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("projectId", null);
|
||||||
|
setProjectSearchTerm("");
|
||||||
|
setProjectPickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">No linked project</div>
|
||||||
|
</button>
|
||||||
|
{projectOptions
|
||||||
|
.filter((option) => {
|
||||||
|
const query = projectSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return option.projectNumber.toLowerCase().includes(query) || option.name.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((option) => (
|
||||||
|
<button key={option.id} type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("projectId", option.id);
|
||||||
|
setProjectSearchTerm(`${option.projectNumber} - ${option.name}`);
|
||||||
|
setProjectPickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">{option.projectNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{option.name} · {option.customerName}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select value={form.status} onChange={(event) => updateField("status", event.target.value as WorkOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{workOrderStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
||||||
|
<input type="number" min={1} step={1} value={form.quantity} onChange={(event) => updateField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
|
||||||
|
<select value={form.warehouseId} onChange={(event) => updateField("warehouseId", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
<option value="">Select warehouse</option>
|
||||||
|
{warehouseOptions.map((option) => <option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
||||||
|
<select value={form.locationId} onChange={(event) => updateField("locationId", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
<option value="">Select location</option>
|
||||||
|
{filteredLocationOptions.map((option) => <option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block max-w-sm">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
|
||||||
|
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span>
|
||||||
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving..." : mode === "create" ? "Create work order" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
110
client/src/modules/manufacturing/WorkOrderListPage.tsx
Normal file
110
client/src/modules/manufacturing/WorkOrderListPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { WorkOrderStatus, WorkOrderSummaryDto } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { workOrderStatusFilters } from "./config";
|
||||||
|
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
|
||||||
|
|
||||||
|
export function WorkOrderListPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | WorkOrderStatus>("ALL");
|
||||||
|
const [status, setStatus] = useState("Loading work orders...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Loading work orders...");
|
||||||
|
api.getWorkOrders(token, { q: query || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
|
||||||
|
.then((nextWorkOrders) => {
|
||||||
|
setWorkOrders(nextWorkOrders);
|
||||||
|
setStatus(nextWorkOrders.length === 0 ? "No work orders matched the current filters." : `${nextWorkOrders.length} work order(s) loaded.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load work orders.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [query, statusFilter, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">Work Orders</h3>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm text-muted">Release and execute build work against manufactured or assembly inventory items, with project linkage and real inventory posting.</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to="/manufacturing/work-orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
|
New work order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search work order, item, or project" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | WorkOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{workOrderStatusFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
</section>
|
||||||
|
{workOrders.length === 0 ? (
|
||||||
|
<div className="rounded-[20px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-[20px] border border-line/70 bg-surface/90 shadow-panel">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/70">
|
||||||
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
<th className="px-3 py-3">Work Order</th>
|
||||||
|
<th className="px-3 py-3">Item</th>
|
||||||
|
<th className="px-3 py-3">Project</th>
|
||||||
|
<th className="px-3 py-3">Status</th>
|
||||||
|
<th className="px-3 py-3">Qty</th>
|
||||||
|
<th className="px-3 py-3">Location</th>
|
||||||
|
<th className="px-3 py-3">Ops</th>
|
||||||
|
<th className="px-3 py-3">Due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70">
|
||||||
|
{workOrders.map((workOrder) => (
|
||||||
|
<tr key={workOrder.id} className="bg-surface/70 transition hover:bg-page/60">
|
||||||
|
<td className="px-3 py-3 align-top">
|
||||||
|
<Link to={`/manufacturing/work-orders/${workOrder.id}`} className="font-semibold text-text hover:text-brand">{workOrder.workOrderNumber}</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 align-top">
|
||||||
|
<div className="font-semibold text-text">{workOrder.itemSku}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{workOrder.itemName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 align-top text-text">{workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "Unlinked"}</td>
|
||||||
|
<td className="px-3 py-3 align-top"><WorkOrderStatusBadge status={workOrder.status} /></td>
|
||||||
|
<td className="px-3 py-3 align-top text-text">{workOrder.completedQuantity} / {workOrder.quantity}</td>
|
||||||
|
<td className="px-3 py-3 align-top text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</td>
|
||||||
|
<td className="px-3 py-3 align-top text-text">{workOrder.operationCount} / {Math.round(workOrder.totalPlannedMinutes / 60)}h</td>
|
||||||
|
<td className="px-3 py-3 align-top text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { WorkOrderStatus } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { workOrderStatusPalette } from "./config";
|
||||||
|
|
||||||
|
export function WorkOrderStatusBadge({ status }: { status: WorkOrderStatus }) {
|
||||||
|
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${workOrderStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
|
||||||
|
}
|
||||||
50
client/src/modules/manufacturing/config.ts
Normal file
50
client/src/modules/manufacturing/config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { WorkOrderCompletionInput, WorkOrderInput, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
|
||||||
|
|
||||||
|
export const workOrderStatusOptions: Array<{ value: WorkOrderStatus; label: string }> = [
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
{ value: "RELEASED", label: "Released" },
|
||||||
|
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||||
|
{ value: "ON_HOLD", label: "On Hold" },
|
||||||
|
{ value: "COMPLETE", label: "Complete" },
|
||||||
|
{ value: "CANCELLED", label: "Cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const workOrderStatusFilters: Array<{ value: "ALL" | WorkOrderStatus; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All statuses" },
|
||||||
|
...workOrderStatusOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const workOrderStatusPalette: Record<WorkOrderStatus, string> = {
|
||||||
|
DRAFT: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
RELEASED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
IN_PROGRESS: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
COMPLETE: "border border-brand/30 bg-brand/10 text-brand",
|
||||||
|
CANCELLED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyWorkOrderInput: WorkOrderInput = {
|
||||||
|
itemId: "",
|
||||||
|
projectId: null,
|
||||||
|
salesOrderId: null,
|
||||||
|
salesOrderLineId: null,
|
||||||
|
status: "DRAFT",
|
||||||
|
quantity: 1,
|
||||||
|
warehouseId: "",
|
||||||
|
locationId: "",
|
||||||
|
dueDate: null,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyMaterialIssueInput: WorkOrderMaterialIssueInput = {
|
||||||
|
componentItemId: "",
|
||||||
|
warehouseId: "",
|
||||||
|
locationId: "",
|
||||||
|
quantity: 1,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyCompletionInput: WorkOrderCompletionInput = {
|
||||||
|
quantity: 1,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
190
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
190
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
||||||
|
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
import type { WorkOrderSummaryDto } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
|
||||||
|
import { ProjectStatusBadge } from "./ProjectStatusBadge";
|
||||||
|
|
||||||
|
export function ProjectDetailPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { projectId } = useParams();
|
||||||
|
const [project, setProject] = useState<ProjectDetailDto | null>(null);
|
||||||
|
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
||||||
|
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||||
|
const [status, setStatus] = useState("Loading project...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getProject(token, projectId)
|
||||||
|
.then((nextProject) => {
|
||||||
|
setProject(nextProject);
|
||||||
|
setStatus("Project loaded.");
|
||||||
|
if (nextProject.salesOrderId) {
|
||||||
|
api.getSalesOrderPlanning(token, nextProject.salesOrderId).then(setPlanning).catch(() => setPlanning(null));
|
||||||
|
} else {
|
||||||
|
setPlanning(null);
|
||||||
|
}
|
||||||
|
return api.getWorkOrders(token, { projectId: nextProject.id });
|
||||||
|
})
|
||||||
|
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load project.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [projectId, token]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{project.projectNumber}</h3>
|
||||||
|
<p className="mt-1 text-sm text-text">{project.name}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<ProjectStatusBadge status={project.status} />
|
||||||
|
<ProjectPriorityBadge priority={project.priority} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/projects" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to projects</Link>
|
||||||
|
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit project</Link> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<article className="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="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="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="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>
|
||||||
|
</section>
|
||||||
|
<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">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p>
|
||||||
|
<dl className="mt-5 grid gap-3">
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</Link></dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{project.customerEmail}</dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Program Notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Commercial + Delivery Links</p>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sales Order</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{planning ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Readiness</p>
|
||||||
|
<div className="mt-5 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="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="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="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>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{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>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Links</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
New work order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<FileAttachmentsPanel
|
||||||
|
ownerType="PROJECT"
|
||||||
|
ownerId={project.id}
|
||||||
|
eyebrow="Project Documents"
|
||||||
|
title="Program file hub"
|
||||||
|
description="Store drawings, revision references, correspondence, and support files directly on the project record."
|
||||||
|
emptyMessage="No project files have been uploaded yet."
|
||||||
|
/>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
544
client/src/modules/projects/ProjectFormPage.tsx
Normal file
544
client/src/modules/projects/ProjectFormPage.tsx
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
import type {
|
||||||
|
ProjectCustomerOptionDto,
|
||||||
|
ProjectDocumentOptionDto,
|
||||||
|
ProjectInput,
|
||||||
|
ProjectOwnerOptionDto,
|
||||||
|
ProjectShipmentOptionDto,
|
||||||
|
} from "@mrp/shared/dist/projects/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
|
||||||
|
|
||||||
|
type ProjectPendingConfirmation =
|
||||||
|
| { kind: "change-customer"; customerId: string; customerName: string }
|
||||||
|
| { kind: "unlink-quote" }
|
||||||
|
| { kind: "unlink-order" }
|
||||||
|
| { kind: "unlink-shipment" };
|
||||||
|
|
||||||
|
export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { projectId } = useParams();
|
||||||
|
const [form, setForm] = useState<ProjectInput>(() => ({ ...emptyProjectInput, ownerId: user?.id ?? null }));
|
||||||
|
const [customerOptions, setCustomerOptions] = useState<ProjectCustomerOptionDto[]>([]);
|
||||||
|
const [ownerOptions, setOwnerOptions] = useState<ProjectOwnerOptionDto[]>([]);
|
||||||
|
const [quoteOptions, setQuoteOptions] = useState<ProjectDocumentOptionDto[]>([]);
|
||||||
|
const [orderOptions, setOrderOptions] = useState<ProjectDocumentOptionDto[]>([]);
|
||||||
|
const [shipmentOptions, setShipmentOptions] = useState<ProjectShipmentOptionDto[]>([]);
|
||||||
|
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
|
||||||
|
const [ownerSearchTerm, setOwnerSearchTerm] = useState("");
|
||||||
|
const [quoteSearchTerm, setQuoteSearchTerm] = useState("");
|
||||||
|
const [orderSearchTerm, setOrderSearchTerm] = useState("");
|
||||||
|
const [shipmentSearchTerm, setShipmentSearchTerm] = useState("");
|
||||||
|
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
|
||||||
|
const [ownerPickerOpen, setOwnerPickerOpen] = useState(false);
|
||||||
|
const [quotePickerOpen, setQuotePickerOpen] = useState(false);
|
||||||
|
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
|
||||||
|
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
|
||||||
|
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getProjectCustomerOptions(token).then(setCustomerOptions).catch(() => setCustomerOptions([]));
|
||||||
|
api.getProjectOwnerOptions(token).then(setOwnerOptions).catch(() => setOwnerOptions([]));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !form.customerId) {
|
||||||
|
setQuoteOptions([]);
|
||||||
|
setOrderOptions([]);
|
||||||
|
setShipmentOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getProjectQuoteOptions(token, form.customerId).then(setQuoteOptions).catch(() => setQuoteOptions([]));
|
||||||
|
api.getProjectOrderOptions(token, form.customerId).then(setOrderOptions).catch(() => setOrderOptions([]));
|
||||||
|
api.getProjectShipmentOptions(token, form.customerId).then(setShipmentOptions).catch(() => setShipmentOptions([]));
|
||||||
|
}, [form.customerId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || mode !== "edit" || !projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getProject(token, projectId)
|
||||||
|
.then((project) => {
|
||||||
|
setForm({
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
priority: project.priority,
|
||||||
|
customerId: project.customerId,
|
||||||
|
salesQuoteId: project.salesQuoteId,
|
||||||
|
salesOrderId: project.salesOrderId,
|
||||||
|
shipmentId: project.shipmentId,
|
||||||
|
ownerId: project.ownerId,
|
||||||
|
dueDate: project.dueDate,
|
||||||
|
notes: project.notes,
|
||||||
|
});
|
||||||
|
setCustomerSearchTerm(project.customerName);
|
||||||
|
setOwnerSearchTerm(project.ownerName ?? "");
|
||||||
|
setQuoteSearchTerm(project.salesQuoteNumber ?? "");
|
||||||
|
setOrderSearchTerm(project.salesOrderNumber ?? "");
|
||||||
|
setShipmentSearchTerm(project.shipmentNumber ?? "");
|
||||||
|
setStatus("Project loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load project.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [mode, projectId, token]);
|
||||||
|
|
||||||
|
function updateField<Key extends keyof ProjectInput>(key: Key, value: ProjectInput[Key]) {
|
||||||
|
setForm((current: ProjectInput) => ({
|
||||||
|
...current,
|
||||||
|
[key]: value,
|
||||||
|
...(key === "customerId"
|
||||||
|
? {
|
||||||
|
salesQuoteId: null,
|
||||||
|
salesOrderId: null,
|
||||||
|
shipmentId: null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLinkedCommercialRecords() {
|
||||||
|
return Boolean(form.salesQuoteId || form.salesOrderId || form.shipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomerSelection(customerId: string, customerName: string) {
|
||||||
|
updateField("customerId", customerId);
|
||||||
|
setCustomerSearchTerm(customerName);
|
||||||
|
setCustomerPickerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCustomerSelection(customerId: string, customerName: string) {
|
||||||
|
if (form.customerId && form.customerId !== customerId && hasLinkedCommercialRecords()) {
|
||||||
|
setPendingConfirmation({ kind: "change-customer", customerId, customerName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCustomerSelection(customerId, customerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkQuote() {
|
||||||
|
updateField("salesQuoteId", null);
|
||||||
|
setQuoteSearchTerm("");
|
||||||
|
setQuotePickerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkOrder() {
|
||||||
|
updateField("salesOrderId", null);
|
||||||
|
setOrderSearchTerm("");
|
||||||
|
setOrderPickerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkShipment() {
|
||||||
|
updateField("shipmentId", null);
|
||||||
|
setShipmentSearchTerm("");
|
||||||
|
setShipmentPickerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSearchTerms() {
|
||||||
|
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
|
||||||
|
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
|
||||||
|
const selectedQuote = quoteOptions.find((quote) => quote.id === form.salesQuoteId);
|
||||||
|
const selectedOrder = orderOptions.find((order) => order.id === form.salesOrderId);
|
||||||
|
const selectedShipment = shipmentOptions.find((shipment) => shipment.id === form.shipmentId);
|
||||||
|
|
||||||
|
setCustomerSearchTerm(selectedCustomer?.name ?? "");
|
||||||
|
setOwnerSearchTerm(selectedOwner?.fullName ?? "");
|
||||||
|
setQuoteSearchTerm(selectedQuote?.documentNumber ?? "");
|
||||||
|
setOrderSearchTerm(selectedOrder?.documentNumber ?? "");
|
||||||
|
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Saving project...");
|
||||||
|
try {
|
||||||
|
const saved = mode === "create" ? await api.createProject(token, form) : await api.updateProject(token, projectId ?? "", form);
|
||||||
|
navigate(`/projects/${saved.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save project.";
|
||||||
|
setStatus(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects Editor</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Project" : "Edit Project"}</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">Create a customer-linked program record that can anchor commercial documents, delivery work, and project files.</p>
|
||||||
|
</div>
|
||||||
|
<Link to={mode === "create" ? "/projects" : `/projects/${projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Project name</span>
|
||||||
|
<input value={form.name} onChange={(event) => updateField("name", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={customerSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCustomerSearchTerm(event.target.value);
|
||||||
|
setCustomerPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setCustomerPickerOpen(true)}
|
||||||
|
onBlur={() => window.setTimeout(() => {
|
||||||
|
setCustomerPickerOpen(false);
|
||||||
|
restoreSearchTerms();
|
||||||
|
}, 120)}
|
||||||
|
placeholder="Search customer"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{customerPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
{customerOptions
|
||||||
|
.filter((customer) => {
|
||||||
|
const query = customerSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((customer) => (
|
||||||
|
<button key={customer.id} type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
requestCustomerSelection(customer.id, customer.name);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">{customer.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{customer.email}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select value={form.status} onChange={(event) => updateField("status", event.target.value as ProjectInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{projectStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Priority</span>
|
||||||
|
<select value={form.priority} onChange={(event) => updateField("priority", event.target.value as ProjectInput["priority"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{projectPriorityOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Owner</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={ownerSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setOwnerSearchTerm(event.target.value);
|
||||||
|
updateField("ownerId", null);
|
||||||
|
setOwnerPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOwnerPickerOpen(true)}
|
||||||
|
onBlur={() => window.setTimeout(() => {
|
||||||
|
setOwnerPickerOpen(false);
|
||||||
|
restoreSearchTerms();
|
||||||
|
}, 120)}
|
||||||
|
placeholder="Search owner"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{ownerPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
<button type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("ownerId", null);
|
||||||
|
setOwnerSearchTerm("");
|
||||||
|
setOwnerPickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">Unassigned</div>
|
||||||
|
</button>
|
||||||
|
{ownerOptions
|
||||||
|
.filter((owner) => {
|
||||||
|
const query = ownerSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return owner.fullName.toLowerCase().includes(query) || owner.email.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((owner) => (
|
||||||
|
<button key={owner.id} type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("ownerId", owner.id);
|
||||||
|
setOwnerSearchTerm(owner.fullName);
|
||||||
|
setOwnerPickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">{owner.fullName}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{owner.email}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
|
||||||
|
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Quote</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={quoteSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuoteSearchTerm(event.target.value);
|
||||||
|
setQuotePickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setQuotePickerOpen(true)}
|
||||||
|
onBlur={() => window.setTimeout(() => {
|
||||||
|
setQuotePickerOpen(false);
|
||||||
|
restoreSearchTerms();
|
||||||
|
}, 120)}
|
||||||
|
placeholder="Search quote"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{quotePickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
<button type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (form.salesQuoteId) {
|
||||||
|
setPendingConfirmation({ kind: "unlink-quote" });
|
||||||
|
} else {
|
||||||
|
unlinkQuote();
|
||||||
|
}
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">No linked quote</div>
|
||||||
|
</button>
|
||||||
|
{quoteOptions
|
||||||
|
.filter((quote) => {
|
||||||
|
const query = quoteSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return quote.documentNumber.toLowerCase().includes(query) || quote.customerName.toLowerCase().includes(query) || quote.status.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((quote) => (
|
||||||
|
<button key={quote.id} type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("salesQuoteId", quote.id);
|
||||||
|
setQuoteSearchTerm(quote.documentNumber);
|
||||||
|
setQuotePickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">{quote.documentNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{quote.customerName} · {quote.status}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={orderSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setOrderSearchTerm(event.target.value);
|
||||||
|
setOrderPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOrderPickerOpen(true)}
|
||||||
|
onBlur={() => window.setTimeout(() => {
|
||||||
|
setOrderPickerOpen(false);
|
||||||
|
restoreSearchTerms();
|
||||||
|
}, 120)}
|
||||||
|
placeholder="Search sales order"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{orderPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
<button type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (form.salesOrderId) {
|
||||||
|
setPendingConfirmation({ kind: "unlink-order" });
|
||||||
|
} else {
|
||||||
|
unlinkOrder();
|
||||||
|
}
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">No linked sales order</div>
|
||||||
|
</button>
|
||||||
|
{orderOptions
|
||||||
|
.filter((order) => {
|
||||||
|
const query = orderSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return order.documentNumber.toLowerCase().includes(query) || order.customerName.toLowerCase().includes(query) || order.status.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((order) => (
|
||||||
|
<button key={order.id} type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("salesOrderId", order.id);
|
||||||
|
setOrderSearchTerm(order.documentNumber);
|
||||||
|
setOrderPickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">{order.documentNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{order.customerName} · {order.status}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={shipmentSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setShipmentSearchTerm(event.target.value);
|
||||||
|
setShipmentPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShipmentPickerOpen(true)}
|
||||||
|
onBlur={() => window.setTimeout(() => {
|
||||||
|
setShipmentPickerOpen(false);
|
||||||
|
restoreSearchTerms();
|
||||||
|
}, 120)}
|
||||||
|
placeholder="Search shipment"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{shipmentPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
<button type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (form.shipmentId) {
|
||||||
|
setPendingConfirmation({ kind: "unlink-shipment" });
|
||||||
|
} else {
|
||||||
|
unlinkShipment();
|
||||||
|
}
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">No linked shipment</div>
|
||||||
|
</button>
|
||||||
|
{shipmentOptions
|
||||||
|
.filter((shipment) => {
|
||||||
|
const query = shipmentSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return shipment.shipmentNumber.toLowerCase().includes(query) || shipment.salesOrderNumber.toLowerCase().includes(query) || shipment.customerName.toLowerCase().includes(query) || shipment.status.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((shipment) => (
|
||||||
|
<button key={shipment.id} type="button" onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("shipmentId", shipment.id);
|
||||||
|
setShipmentSearchTerm(shipment.shipmentNumber);
|
||||||
|
setShipmentPickerOpen(false);
|
||||||
|
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
|
||||||
|
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{shipment.salesOrderNumber} · {shipment.status}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving..." : mode === "create" ? "Create project" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={
|
||||||
|
pendingConfirmation?.kind === "change-customer"
|
||||||
|
? "Change project customer"
|
||||||
|
: pendingConfirmation?.kind === "unlink-quote"
|
||||||
|
? "Remove linked quote"
|
||||||
|
: pendingConfirmation?.kind === "unlink-order"
|
||||||
|
? "Remove linked sales order"
|
||||||
|
: "Remove linked shipment"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
pendingConfirmation?.kind === "change-customer"
|
||||||
|
? `Switch this project to ${pendingConfirmation.customerName}. Existing quote, sales order, and shipment links will be cleared.`
|
||||||
|
: pendingConfirmation?.kind === "unlink-quote"
|
||||||
|
? "Remove the currently linked quote from this project draft."
|
||||||
|
: pendingConfirmation?.kind === "unlink-order"
|
||||||
|
? "Remove the currently linked sales order from this project draft."
|
||||||
|
: "Remove the currently linked shipment from this project draft."
|
||||||
|
}
|
||||||
|
impact={
|
||||||
|
pendingConfirmation?.kind === "change-customer"
|
||||||
|
? "Commercial and delivery linkage tied to the previous customer will be cleared immediately from the draft."
|
||||||
|
: "The project will no longer point to that related record after you save this edit."
|
||||||
|
}
|
||||||
|
recovery={
|
||||||
|
pendingConfirmation?.kind === "change-customer"
|
||||||
|
? "Re-link the correct quote, order, and shipment before saving if the customer change was accidental."
|
||||||
|
: "Pick the related record again before saving if this unlink was a mistake."
|
||||||
|
}
|
||||||
|
confirmLabel={
|
||||||
|
pendingConfirmation?.kind === "change-customer"
|
||||||
|
? "Change customer"
|
||||||
|
: "Remove link"
|
||||||
|
}
|
||||||
|
onClose={() => setPendingConfirmation(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "change-customer") {
|
||||||
|
applyCustomerSelection(pendingConfirmation.customerId, pendingConfirmation.customerName);
|
||||||
|
} else if (pendingConfirmation.kind === "unlink-quote") {
|
||||||
|
unlinkQuote();
|
||||||
|
} else if (pendingConfirmation.kind === "unlink-order") {
|
||||||
|
unlinkOrder();
|
||||||
|
} else if (pendingConfirmation.kind === "unlink-shipment") {
|
||||||
|
unlinkShipment();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
117
client/src/modules/projects/ProjectListPage.tsx
Normal file
117
client/src/modules/projects/ProjectListPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { ProjectPriority, ProjectStatus, ProjectSummaryDto } from "@mrp/shared/dist/projects/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { projectPriorityFilters, projectStatusFilters } from "./config";
|
||||||
|
import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
|
||||||
|
import { ProjectStatusBadge } from "./ProjectStatusBadge";
|
||||||
|
|
||||||
|
export function ProjectListPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [projects, setProjects] = useState<ProjectSummaryDto[]>([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | ProjectStatus>("ALL");
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState<"ALL" | ProjectPriority>("ALL");
|
||||||
|
const [status, setStatus] = useState("Load projects, linked customer work, and program ownership.");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Loading projects...");
|
||||||
|
api
|
||||||
|
.getProjects(token, {
|
||||||
|
q: query || undefined,
|
||||||
|
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||||
|
priority: priorityFilter === "ALL" ? undefined : priorityFilter,
|
||||||
|
})
|
||||||
|
.then((nextProjects) => {
|
||||||
|
setProjects(nextProjects);
|
||||||
|
setStatus(nextProjects.length === 0 ? "No projects matched the current filters." : `${nextProjects.length} project(s) loaded.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load projects.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [priorityFilter, query, statusFilter, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">Program records</h3>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm text-muted">Track long-running customer programs across commercial commitments, shipment deliverables, ownership, and due dates.</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to="/projects/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
|
New project
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Project number, name, customer" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | ProjectStatus)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{projectStatusFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Priority</span>
|
||||||
|
<select value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value as "ALL" | ProjectPriority)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{projectPriorityFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{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-5 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Project</th>
|
||||||
|
<th className="px-2 py-2">Customer</th>
|
||||||
|
<th className="px-2 py-2">Owner</th>
|
||||||
|
<th className="px-2 py-2">Status</th>
|
||||||
|
<th className="px-2 py-2">Priority</th>
|
||||||
|
<th className="px-2 py-2">Due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<tr key={project.id}>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<Link to={`/projects/${project.id}`} className="font-semibold text-text hover:text-brand">{project.projectNumber}</Link>
|
||||||
|
<div className="mt-1 text-xs text-muted">{project.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{project.customerName}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td>
|
||||||
|
<td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td>
|
||||||
|
<td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td>
|
||||||
|
<td className="px-2 py-2 text-muted">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "No due date"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
7
client/src/modules/projects/ProjectPriorityBadge.tsx
Normal file
7
client/src/modules/projects/ProjectPriorityBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ProjectPriority } from "@mrp/shared/dist/projects/types.js";
|
||||||
|
|
||||||
|
import { projectPriorityPalette } from "./config";
|
||||||
|
|
||||||
|
export function ProjectPriorityBadge({ priority }: { priority: ProjectPriority }) {
|
||||||
|
return <span className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${projectPriorityPalette[priority]}`}>{priority}</span>;
|
||||||
|
}
|
||||||
7
client/src/modules/projects/ProjectStatusBadge.tsx
Normal file
7
client/src/modules/projects/ProjectStatusBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ProjectStatus } from "@mrp/shared/dist/projects/types.js";
|
||||||
|
|
||||||
|
import { projectStatusPalette } from "./config";
|
||||||
|
|
||||||
|
export function ProjectStatusBadge({ status }: { status: ProjectStatus }) {
|
||||||
|
return <span className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${projectStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
|
||||||
|
}
|
||||||
5
client/src/modules/projects/ProjectsPage.tsx
Normal file
5
client/src/modules/projects/ProjectsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProjectListPage } from "./ProjectListPage";
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
return <ProjectListPage />;
|
||||||
|
}
|
||||||
54
client/src/modules/projects/config.ts
Normal file
54
client/src/modules/projects/config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ProjectInput, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js";
|
||||||
|
|
||||||
|
export const projectStatusOptions: Array<{ value: ProjectStatus; label: string }> = [
|
||||||
|
{ value: "PLANNED", label: "Planned" },
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "ON_HOLD", label: "On Hold" },
|
||||||
|
{ value: "AT_RISK", label: "At Risk" },
|
||||||
|
{ value: "COMPLETE", label: "Complete" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projectPriorityOptions: Array<{ value: ProjectPriority; label: string }> = [
|
||||||
|
{ value: "LOW", label: "Low" },
|
||||||
|
{ value: "MEDIUM", label: "Medium" },
|
||||||
|
{ value: "HIGH", label: "High" },
|
||||||
|
{ value: "CRITICAL", label: "Critical" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projectStatusFilters: Array<{ value: "ALL" | ProjectStatus; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All statuses" },
|
||||||
|
...projectStatusOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projectPriorityFilters: Array<{ value: "ALL" | ProjectPriority; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All priorities" },
|
||||||
|
...projectPriorityOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projectStatusPalette: Record<ProjectStatus, string> = {
|
||||||
|
PLANNED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
AT_RISK: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||||
|
COMPLETE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectPriorityPalette: Record<ProjectPriority, string> = {
|
||||||
|
LOW: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
MEDIUM: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
HIGH: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyProjectInput: ProjectInput = {
|
||||||
|
name: "",
|
||||||
|
status: "PLANNED",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
customerId: "",
|
||||||
|
salesQuoteId: null,
|
||||||
|
salesOrderId: null,
|
||||||
|
shipmentId: null,
|
||||||
|
ownerId: null,
|
||||||
|
dueDate: null,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
673
client/src/modules/purchasing/PurchaseDetailPage.tsx
Normal file
673
client/src/modules/purchasing/PurchaseDetailPage.tsx
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared";
|
||||||
|
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||||
|
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||||
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
|
||||||
|
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
|
||||||
|
|
||||||
|
function formatCurrency(value: number) {
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPurchaseDocumentForComparison(
|
||||||
|
document: Pick<
|
||||||
|
PurchaseOrderDetailDto,
|
||||||
|
| "documentNumber"
|
||||||
|
| "vendorName"
|
||||||
|
| "status"
|
||||||
|
| "issueDate"
|
||||||
|
| "taxPercent"
|
||||||
|
| "taxAmount"
|
||||||
|
| "freightAmount"
|
||||||
|
| "subtotal"
|
||||||
|
| "total"
|
||||||
|
| "notes"
|
||||||
|
| "paymentTerms"
|
||||||
|
| "currencyCode"
|
||||||
|
| "lines"
|
||||||
|
| "receipts"
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: document.documentNumber,
|
||||||
|
subtitle: document.vendorName,
|
||||||
|
status: document.status,
|
||||||
|
metaFields: [
|
||||||
|
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
|
||||||
|
{ label: "Payment Terms", value: document.paymentTerms || "N/A" },
|
||||||
|
{ label: "Currency", value: document.currencyCode || "USD" },
|
||||||
|
{ label: "Receipts", value: document.receipts.length.toString() },
|
||||||
|
],
|
||||||
|
totalFields: [
|
||||||
|
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
|
||||||
|
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
|
||||||
|
{ label: "Freight", value: formatCurrency(document.freightAmount) },
|
||||||
|
{ label: "Total", value: formatCurrency(document.total) },
|
||||||
|
],
|
||||||
|
notes: document.notes,
|
||||||
|
lines: document.lines.map((line) => ({
|
||||||
|
key: line.id || `${line.itemId}-${line.position}`,
|
||||||
|
title: `${line.itemSku} | ${line.itemName}`,
|
||||||
|
subtitle: line.description,
|
||||||
|
quantity: `${line.quantity} ${line.unitOfMeasure}`,
|
||||||
|
unitLabel: line.unitOfMeasure,
|
||||||
|
amountLabel: formatCurrency(line.unitCost),
|
||||||
|
totalLabel: formatCurrency(line.lineTotal),
|
||||||
|
extraLabel:
|
||||||
|
`${line.receivedQuantity} received | ${line.remainingQuantity} remaining` +
|
||||||
|
(line.salesOrderNumber ? ` | Demand ${line.salesOrderNumber}` : ""),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseDetailPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { orderId } = useParams();
|
||||||
|
const [document, setDocument] = useState<PurchaseOrderDetailDto | null>(null);
|
||||||
|
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
||||||
|
const [receiptForm, setReceiptForm] = useState<PurchaseReceiptInput>(emptyPurchaseReceiptInput);
|
||||||
|
const [receiptQuantities, setReceiptQuantities] = useState<Record<string, number>>({});
|
||||||
|
const [receiptStatus, setReceiptStatus] = useState("Receive ordered material into inventory against this purchase order.");
|
||||||
|
const [isSavingReceipt, setIsSavingReceipt] = useState(false);
|
||||||
|
const [status, setStatus] = useState("Loading purchase order...");
|
||||||
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
|
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
|
||||||
|
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "status" | "receipt";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
nextStatus?: PurchaseOrderStatus;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes("purchasing.write") ?? false;
|
||||||
|
const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !orderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getPurchaseOrder(token, orderId)
|
||||||
|
.then((nextDocument) => {
|
||||||
|
setDocument(nextDocument);
|
||||||
|
setStatus("Purchase order loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
api.getDemandPlanningRollup(token).then(setPlanningRollup).catch(() => setPlanningRollup(null));
|
||||||
|
|
||||||
|
if (!canReceive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getWarehouseLocationOptions(token)
|
||||||
|
.then((options) => {
|
||||||
|
setLocationOptions(options);
|
||||||
|
setReceiptForm((current: PurchaseReceiptInput) => {
|
||||||
|
if (current.locationId) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstOption = options[0];
|
||||||
|
return firstOption
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
warehouseId: firstOption.warehouseId,
|
||||||
|
locationId: firstOption.locationId,
|
||||||
|
}
|
||||||
|
: current;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setLocationOptions([]));
|
||||||
|
}, [canReceive, orderId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!document) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReceiptQuantities((current) => {
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
for (const line of document.lines) {
|
||||||
|
if (line.remainingQuantity > 0) {
|
||||||
|
next[line.id] = current[line.id] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [document]);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDocument = document;
|
||||||
|
const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0);
|
||||||
|
const demandContextItems =
|
||||||
|
planningRollup?.items.filter((item) => activeDocument.lines.some((line) => line.itemId === item.itemId) && (item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)) ?? [];
|
||||||
|
|
||||||
|
function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) {
|
||||||
|
setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReceiptQuantity(lineId: string, quantity: number) {
|
||||||
|
setReceiptQuantities((current: Record<string, number>) => ({
|
||||||
|
...current,
|
||||||
|
[lineId]: quantity,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStatusChange(nextStatus: PurchaseOrderStatus) {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingStatus(true);
|
||||||
|
setStatus("Updating purchase order status...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextDocument = await api.updatePurchaseOrderStatus(token, activeDocument.id, nextStatus);
|
||||||
|
setDocument(nextDocument);
|
||||||
|
setStatus("Purchase order status updated. Confirm vendor communication and receiving expectations if this moved the order into a terminal state.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to update purchase order status.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyReceipt() {
|
||||||
|
if (!token || !canReceive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingReceipt(true);
|
||||||
|
setReceiptStatus("Posting purchase receipt...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: PurchaseReceiptInput = {
|
||||||
|
...receiptForm,
|
||||||
|
lines: openLines
|
||||||
|
.map((line) => ({
|
||||||
|
purchaseOrderLineId: line.id,
|
||||||
|
quantity: Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)),
|
||||||
|
}))
|
||||||
|
.filter((line) => line.quantity > 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextDocument = await api.createPurchaseReceipt(token, activeDocument.id, payload);
|
||||||
|
setDocument(nextDocument);
|
||||||
|
setReceiptQuantities({});
|
||||||
|
setReceiptForm((current: PurchaseReceiptInput) => ({
|
||||||
|
...current,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
notes: "",
|
||||||
|
}));
|
||||||
|
setReceiptStatus("Purchase receipt recorded. Inventory has been increased; verify stock balances and post a correcting movement if quantities were overstated.");
|
||||||
|
setStatus("Purchase order updated after receipt.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to record purchase receipt.";
|
||||||
|
setReceiptStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSavingReceipt(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(nextStatus: PurchaseOrderStatus) {
|
||||||
|
const label = purchaseStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "status",
|
||||||
|
title: `Set purchase order to ${label}`,
|
||||||
|
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
|
||||||
|
impact:
|
||||||
|
nextStatus === "CLOSED"
|
||||||
|
? "This closes the order operationally and can change inbound supply expectations, shortage coverage, and vendor follow-up."
|
||||||
|
: "This changes the purchasing state used by receiving, planning, and audit review.",
|
||||||
|
recovery: "If the status is wrong, set the order back to the correct state and verify any downstream receiving or planning assumptions.",
|
||||||
|
confirmLabel: `Set ${label}`,
|
||||||
|
confirmationLabel: nextStatus === "CLOSED" ? "Type purchase order number to confirm:" : undefined,
|
||||||
|
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
|
||||||
|
nextStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReceiptSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const totalReceiptQuantity = openLines.reduce((sum, line) => sum + Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)), 0);
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "receipt",
|
||||||
|
title: "Post purchase receipt",
|
||||||
|
description: `Receive ${totalReceiptQuantity} total units into ${receiptForm.warehouseId && receiptForm.locationId ? "the selected stock location" : "inventory"} for ${activeDocument.documentNumber}.`,
|
||||||
|
impact: "This increases inventory immediately and becomes part of the PO receipt history.",
|
||||||
|
recovery: "If quantities are wrong, post the correcting inventory movement and review the remaining quantities on the purchase order.",
|
||||||
|
confirmLabel: "Post receipt",
|
||||||
|
confirmationLabel: totalReceiptQuantity > 0 ? "Type purchase order number to confirm:" : undefined,
|
||||||
|
confirmationValue: totalReceiptQuantity > 0 ? activeDocument.documentNumber : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenPdf() {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpeningPdf(true);
|
||||||
|
setStatus("Rendering purchase order PDF...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await api.getPurchaseOrderPdf(token, activeDocument.id);
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
setStatus("Purchase order PDF ready.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to render purchase order PDF.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsOpeningPdf(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Order</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
|
||||||
|
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<PurchaseStatusBadge status={activeDocument.status} />
|
||||||
|
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||||
|
Rev {activeDocument.revisions[0]?.revisionNumber ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Back to purchase orders
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenPdf}
|
||||||
|
disabled={isOpeningPdf}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
|
||||||
|
</button>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`/purchasing/orders/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
|
Edit purchase order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Update purchase-order status without opening the full editor.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{purchaseStatusOptions.map((option) => (
|
||||||
|
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || activeDocument.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="grid gap-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">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="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="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>
|
||||||
|
</section>
|
||||||
|
<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">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="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="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="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="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>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Revision History</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Automatic snapshots are recorded when the purchase order changes or receipts are posted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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">
|
||||||
|
No revisions have been recorded yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{activeDocument.revisions.map((revision) => (
|
||||||
|
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
|
||||||
|
<div className="mt-1 text-sm text-text">{revision.reason}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted">
|
||||||
|
<div>{new Date(revision.createdAt).toLocaleString()}</div>
|
||||||
|
<div className="mt-1">{revision.createdByName ?? "System"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{activeDocument.revisions.length > 0 ? (
|
||||||
|
<DocumentRevisionComparison
|
||||||
|
title="Revision Comparison"
|
||||||
|
description="Compare earlier purchase-order revisions against the current document or another revision to review commercial, receiving, and line-level changes."
|
||||||
|
currentLabel="Current document"
|
||||||
|
currentDocument={mapPurchaseDocumentForComparison(activeDocument)}
|
||||||
|
revisions={activeDocument.revisions.map((revision) => ({
|
||||||
|
id: revision.id,
|
||||||
|
label: `Rev ${revision.revisionNumber}`,
|
||||||
|
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
|
||||||
|
}))}
|
||||||
|
getRevisionDocument={(revisionId) => {
|
||||||
|
if (revisionId === "current") {
|
||||||
|
return mapPurchaseDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
|
||||||
|
if (!revision) {
|
||||||
|
return mapPurchaseDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapPurchaseDocumentForComparison({
|
||||||
|
...revision.snapshot,
|
||||||
|
lines: revision.snapshot.lines.map((line) => ({
|
||||||
|
id: `${line.itemId}-${line.position}`,
|
||||||
|
...line,
|
||||||
|
})),
|
||||||
|
receipts: revision.snapshot.receipts,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor</p>
|
||||||
|
<dl className="mt-5 grid gap-3">
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/vendors/${activeDocument.vendorId}`} className="hover:text-brand">{activeDocument.vendorName}</Link></dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorEmail}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Context</p>
|
||||||
|
{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">
|
||||||
|
No active shared shortage or buy-signal records currently point at items on this purchase order.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{demandContextItems.map((item) => (
|
||||||
|
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{item.itemSku}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||||
|
{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-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
|
||||||
|
<tr key={line.id}>
|
||||||
|
<td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.description}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">
|
||||||
|
{line.salesOrderId && line.salesOrderNumber ? <Link to={`/sales/orders/${line.salesOrderId}`} className="hover:text-brand">{line.salesOrderNumber}</Link> : "Unlinked"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.quantity}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.receivedQuantity}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.remainingQuantity}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">${line.unitCost.toFixed(2)}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
|
||||||
|
{canReceive ? (
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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 ? (
|
||||||
|
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||||
|
All ordered quantities have been received for this purchase order.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="mt-5 space-y-4" onSubmit={handleReceiptSubmit}>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Receipt date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={receiptForm.receivedAt.slice(0, 10)}
|
||||||
|
onChange={(event) => updateReceiptField("receivedAt", new Date(event.target.value).toISOString())}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
|
||||||
|
<select
|
||||||
|
value={receiptForm.locationId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
|
||||||
|
updateReceiptField("locationId", event.target.value);
|
||||||
|
if (nextLocation) {
|
||||||
|
updateReceiptField("warehouseId", nextLocation.warehouseId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{locationOptions.map((option) => (
|
||||||
|
<option key={option.locationId} value={option.locationId}>
|
||||||
|
{option.warehouseCode} / {option.locationCode}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea
|
||||||
|
value={receiptForm.notes}
|
||||||
|
onChange={(event) => updateReceiptField("notes", event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{openLines.map((line) => (
|
||||||
|
<div key={line.id} className="grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[minmax(0,1.3fr)_0.6fr_0.7fr_0.7fr]">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{line.itemSku}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">{line.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Remaining</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">{line.remainingQuantity}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Received</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">{line.receivedQuantity}</div>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Receive Now</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={line.remainingQuantity}
|
||||||
|
step={1}
|
||||||
|
value={receiptQuantities[line.id] ?? 0}
|
||||||
|
onChange={(event) => updateReceiptQuantity(line.id, Number.parseInt(event.target.value, 10) || 0)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-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">{receiptStatus}</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingReceipt || locationOptions.length === 0}
|
||||||
|
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSavingReceipt ? "Posting..." : "Post receipt"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Receipt History</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Received material log</h4>
|
||||||
|
{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">
|
||||||
|
No purchase receipts have been recorded for this order yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{activeDocument.receipts.map((receipt) => (
|
||||||
|
<article key={receipt.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text">{receipt.receiptNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
{receipt.warehouseCode} / {receipt.locationCode} · {receipt.totalQuantity} units across {receipt.lineCount} line{receipt.lineCount === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">
|
||||||
|
{receipt.warehouseName} · {receipt.locationName}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{receipt.lines.map((line) => (
|
||||||
|
<div key={line.id} className="text-sm text-text">
|
||||||
|
<span className="font-semibold">{line.itemSku}</span> · {line.quantity}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{receipt.notes ? <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{receipt.notes}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted lg:text-right">
|
||||||
|
<div>{new Date(receipt.receivedAt).toLocaleDateString()}</div>
|
||||||
|
<div className="mt-1">{receipt.createdByName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<FileAttachmentsPanel
|
||||||
|
ownerType="PURCHASE_ORDER"
|
||||||
|
ownerId={activeDocument.id}
|
||||||
|
eyebrow="Supporting Documents"
|
||||||
|
title="Vendor invoices and backup"
|
||||||
|
description="Store vendor invoices, acknowledgements, certifications, and supporting procurement documents directly on the purchase order."
|
||||||
|
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet."
|
||||||
|
/>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm purchasing action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={
|
||||||
|
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||||
|
(pendingConfirmation?.kind === "receipt" && isSavingReceipt)
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isUpdatingStatus && !isSavingReceipt) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||||
|
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "receipt") {
|
||||||
|
await applyReceipt();
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
492
client/src/modules/purchasing/PurchaseFormPage.tsx
Normal file
492
client/src/modules/purchasing/PurchaseFormPage.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { inventoryUnitOptions } from "../inventory/config";
|
||||||
|
import { emptyPurchaseOrderInput, purchaseStatusOptions } from "./config";
|
||||||
|
|
||||||
|
export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { orderId } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const seededVendorId = searchParams.get("vendorId");
|
||||||
|
const planningOrderId = searchParams.get("planningOrderId");
|
||||||
|
const selectedPlanningItemId = searchParams.get("itemId");
|
||||||
|
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
|
||||||
|
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
|
||||||
|
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
|
||||||
|
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
|
||||||
|
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
|
||||||
|
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
|
||||||
|
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
|
||||||
|
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] {
|
||||||
|
const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : [];
|
||||||
|
for (const child of node.children) {
|
||||||
|
nodes.push(...collectRecommendedPurchaseNodes(child));
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0);
|
||||||
|
const taxAmount = subtotal * (form.taxPercent / 100);
|
||||||
|
const total = subtotal + taxAmount + form.freightAmount;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getPurchaseVendors(token).then((nextVendors) => {
|
||||||
|
setVendors(nextVendors);
|
||||||
|
if (mode === "create" && seededVendorId) {
|
||||||
|
const seededVendor = nextVendors.find((vendor) => vendor.id === seededVendorId);
|
||||||
|
if (seededVendor) {
|
||||||
|
setForm((current: PurchaseOrderInput) => ({ ...current, vendorId: seededVendor.id }));
|
||||||
|
setVendorSearchTerm(seededVendor.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => setVendors([]));
|
||||||
|
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
|
||||||
|
}, [mode, seededVendorId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getSalesOrderPlanning(token, planningOrderId)
|
||||||
|
.then((planning: SalesOrderPlanningDto) => {
|
||||||
|
const recommendedNodes = planning.lines.flatMap((line) =>
|
||||||
|
collectRecommendedPurchaseNodes(line.rootNode).map((node) => ({
|
||||||
|
salesOrderLineId: node.itemId === line.itemId ? line.lineId : null,
|
||||||
|
...node,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const filteredNodes = selectedPlanningItemId
|
||||||
|
? recommendedNodes.filter((node) => node.itemId === selectedPlanningItemId)
|
||||||
|
: recommendedNodes;
|
||||||
|
const recommendedLines = filteredNodes.map((node, index) => {
|
||||||
|
const inventoryItem = itemOptions.find((option) => option.id === node.itemId);
|
||||||
|
return {
|
||||||
|
itemId: node.itemId,
|
||||||
|
description: node.itemName,
|
||||||
|
quantity: node.recommendedPurchaseQuantity,
|
||||||
|
unitOfMeasure: node.unitOfMeasure,
|
||||||
|
unitCost: inventoryItem?.defaultCost ?? 0,
|
||||||
|
salesOrderId: planning.orderId,
|
||||||
|
salesOrderLineId: node.salesOrderLineId,
|
||||||
|
position: (index + 1) * 10,
|
||||||
|
} satisfies PurchaseLineInput;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recommendedLines.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredVendorIds = [
|
||||||
|
...new Set(
|
||||||
|
recommendedLines
|
||||||
|
.map((line) => itemOptions.find((option) => option.id === line.itemId)?.preferredVendorId)
|
||||||
|
.filter((vendorId): vendorId is string => Boolean(vendorId))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const autoVendorId = seededVendorId || (preferredVendorIds.length === 1 ? preferredVendorIds[0] : null);
|
||||||
|
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
vendorId: current.vendorId || autoVendorId || "",
|
||||||
|
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
|
||||||
|
lines: current.lines.length > 0 ? current.lines : recommendedLines,
|
||||||
|
}));
|
||||||
|
if (autoVendorId) {
|
||||||
|
const autoVendor = vendors.find((vendor) => vendor.id === autoVendorId);
|
||||||
|
if (autoVendor) {
|
||||||
|
setVendorSearchTerm(autoVendor.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLineSearchTerms((current) =>
|
||||||
|
current.length > 0 ? current : recommendedLines.map((line) => itemOptions.find((option) => option.id === line.itemId)?.sku ?? "")
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
preferredVendorIds.length > 1 && !seededVendorId
|
||||||
|
? `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}. Multiple preferred vendors exist, so confirm the vendor before saving.`
|
||||||
|
: `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}.`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setStatus("Unable to load demand-planning recommendations.");
|
||||||
|
});
|
||||||
|
}, [itemOptions, mode, planningOrderId, seededVendorId, selectedPlanningItemId, token, vendors]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || mode !== "edit" || !orderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getPurchaseOrder(token, orderId)
|
||||||
|
.then((document) => {
|
||||||
|
setForm({
|
||||||
|
vendorId: document.vendorId,
|
||||||
|
status: document.status,
|
||||||
|
issueDate: document.issueDate,
|
||||||
|
taxPercent: document.taxPercent,
|
||||||
|
freightAmount: document.freightAmount,
|
||||||
|
notes: document.notes,
|
||||||
|
revisionReason: "",
|
||||||
|
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
|
||||||
|
itemId: line.itemId,
|
||||||
|
description: line.description,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitOfMeasure: line.unitOfMeasure,
|
||||||
|
unitCost: line.unitCost,
|
||||||
|
salesOrderId: line.salesOrderId,
|
||||||
|
salesOrderLineId: line.salesOrderLineId,
|
||||||
|
position: line.position,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setVendorSearchTerm(document.vendorName);
|
||||||
|
setLineSearchTerms(document.lines.map((line: { itemSku: string }) => line.itemSku));
|
||||||
|
setStatus("Purchase order loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [mode, orderId, token]);
|
||||||
|
|
||||||
|
function updateField<Key extends keyof PurchaseOrderInput>(key: Key, value: PurchaseOrderInput[Key]) {
|
||||||
|
setForm((current: PurchaseOrderInput) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedVendorName(vendorId: string) {
|
||||||
|
return vendors.find((vendor) => vendor.id === vendorId)?.name ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedVendor(vendorId: string) {
|
||||||
|
return vendors.find((vendor) => vendor.id === vendorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(index: number, nextLine: PurchaseLineInput) {
|
||||||
|
setForm((current: PurchaseOrderInput) => ({
|
||||||
|
...current,
|
||||||
|
lines: current.lines.map((line: PurchaseLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLineSearchTerm(index: number, value: string) {
|
||||||
|
setLineSearchTerms((current) => {
|
||||||
|
const next = [...current];
|
||||||
|
next[index] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setForm((current: PurchaseOrderInput) => ({
|
||||||
|
...current,
|
||||||
|
lines: [
|
||||||
|
...current.lines,
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
description: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitOfMeasure: "EA",
|
||||||
|
unitCost: 0,
|
||||||
|
position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: PurchaseLineInput) => line.position)) + 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
setLineSearchTerms((current) => [...current, ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
setForm((current: PurchaseOrderInput) => ({
|
||||||
|
...current,
|
||||||
|
lines: current.lines.filter((_line: PurchaseLineInput, lineIndex: number) => lineIndex !== index),
|
||||||
|
}));
|
||||||
|
setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingLineRemoval =
|
||||||
|
pendingLineRemovalIndex != null
|
||||||
|
? {
|
||||||
|
index: pendingLineRemovalIndex,
|
||||||
|
line: form.lines[pendingLineRemovalIndex],
|
||||||
|
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Saving purchase order...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = mode === "create" ? await api.createPurchaseOrder(token, form) : await api.updatePurchaseOrder(token, orderId ?? "", form);
|
||||||
|
navigate(`/purchasing/orders/${saved.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save purchase order.";
|
||||||
|
setStatus(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredVendorCount = vendors.filter((vendor) => {
|
||||||
|
const query = vendorSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Editor</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
|
||||||
|
</div>
|
||||||
|
<Link to={mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<label className="block xl:col-span-2">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Vendor</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={vendorSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setVendorSearchTerm(event.target.value);
|
||||||
|
updateField("vendorId", "");
|
||||||
|
setVendorPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setVendorPickerOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setVendorPickerOpen(false);
|
||||||
|
if (form.vendorId) {
|
||||||
|
setVendorSearchTerm(getSelectedVendorName(form.vendorId));
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
placeholder="Search vendor"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{vendorPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
{vendors
|
||||||
|
.filter((vendor) => {
|
||||||
|
const query = vendorSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((vendor) => (
|
||||||
|
<button
|
||||||
|
key={vendor.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("vendorId", vendor.id);
|
||||||
|
setVendorSearchTerm(vendor.name);
|
||||||
|
setVendorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-text">{vendor.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredVendorCount === 0 ? <div className="px-2 py-2 text-sm text-muted">No matching vendors found.</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 min-h-5 text-xs text-muted">{form.vendorId ? getSelectedVendorName(form.vendorId) : "No vendor selected"}</div>
|
||||||
|
{form.vendorId ? (
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
Terms: {getSelectedVendor(form.vendorId)?.paymentTerms || "N/A"} | Currency: {getSelectedVendor(form.vendorId)?.currencyCode || "USD"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select value={form.status} onChange={(event) => updateField("status", event.target.value as PurchaseOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{purchaseStatusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Issue date</span>
|
||||||
|
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<label className="block xl:max-w-xl">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Revision Reason</span>
|
||||||
|
<input
|
||||||
|
value={form.revisionReason ?? ""}
|
||||||
|
onChange={(event) => updateField("revisionReason", event.target.value)}
|
||||||
|
placeholder="What changed in this revision?"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
|
||||||
|
<input type="number" min={0} max={100} step={0.01} value={form.taxPercent} onChange={(event) => updateField("taxPercent", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Freight</span>
|
||||||
|
<input type="number" min={0} step={0.01} value={form.freightAmount} onChange={(event) => updateField("freightAmount", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Procurement lines</h4>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add line</button>
|
||||||
|
</div>
|
||||||
|
{form.lines.length === 0 ? (
|
||||||
|
<div className="mt-5 rounded-[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-5 space-y-4">
|
||||||
|
{form.lines.map((line: PurchaseLineInput, index: number) => (
|
||||||
|
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={lineSearchTerms[index] ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
updateLineSearchTerm(index, event.target.value);
|
||||||
|
updateLine(index, { ...line, itemId: "" });
|
||||||
|
setActiveLinePicker(index);
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveLinePicker(index)}
|
||||||
|
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
|
||||||
|
placeholder="Search by SKU"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{activeLinePicker === index ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
{itemOptions
|
||||||
|
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateLine(index, {
|
||||||
|
...line,
|
||||||
|
itemId: option.id,
|
||||||
|
description: line.description || option.name,
|
||||||
|
salesOrderId: line.salesOrderId ?? null,
|
||||||
|
salesOrderLineId: line.salesOrderLineId ?? null,
|
||||||
|
});
|
||||||
|
updateLineSearchTerm(index, option.sku);
|
||||||
|
setActiveLinePicker(null);
|
||||||
|
}}
|
||||||
|
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
|
||||||
|
>
|
||||||
|
{option.sku}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||||
|
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
|
||||||
|
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
|
||||||
|
<select value={line.unitOfMeasure} onChange={(event) => updateLine(index, { ...line, unitOfMeasure: event.target.value as PurchaseLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{inventoryUnitOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Unit Cost</span>
|
||||||
|
<input type="number" min={0} step={0.01} value={line.unitCost} onChange={(event) => updateLine(index, { ...line, unitCost: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end"><div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">${(line.quantity * line.unitCost).toFixed(2)}</div></div>
|
||||||
|
<div className="flex items-end"><button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">Remove</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div><div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div></div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Tax</div><div className="mt-1 font-semibold text-text">${taxAmount.toFixed(2)}</div></div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Freight</div><div className="mt-1 font-semibold text-text">${form.freightAmount.toFixed(2)}</div></div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div><div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving..." : mode === "create" ? "Create purchase order" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingLineRemoval != null}
|
||||||
|
title="Remove purchase line"
|
||||||
|
description={
|
||||||
|
pendingLineRemoval
|
||||||
|
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the purchase order draft.`
|
||||||
|
: "Remove this purchase line."
|
||||||
|
}
|
||||||
|
impact="The line will be removed from the draft immediately and purchasing totals will recalculate."
|
||||||
|
recovery="Re-add the line before saving if the removal was accidental."
|
||||||
|
confirmLabel="Remove line"
|
||||||
|
onClose={() => setPendingLineRemovalIndex(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingLineRemoval) {
|
||||||
|
removeLine(pendingLineRemoval.index);
|
||||||
|
}
|
||||||
|
setPendingLineRemovalIndex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
100
client/src/modules/purchasing/PurchaseListPage.tsx
Normal file
100
client/src/modules/purchasing/PurchaseListPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { PurchaseOrderStatus, PurchaseOrderSummaryDto } from "@mrp/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { purchaseStatusFilters } from "./config";
|
||||||
|
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
|
||||||
|
|
||||||
|
export function PurchaseListPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [documents, setDocuments] = useState<PurchaseOrderSummaryDto[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | PurchaseOrderStatus>("ALL");
|
||||||
|
const [status, setStatus] = useState("Loading purchase orders...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes("purchasing.write") ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getPurchaseOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
|
||||||
|
.then((nextDocuments) => {
|
||||||
|
setDocuments(nextDocuments);
|
||||||
|
setStatus(`${nextDocuments.length} purchase orders matched the current filters.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load purchase orders.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [searchTerm, statusFilter, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Purchase Orders</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">Vendor-facing procurement documents for material replenishment and bought-in components.</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to="/purchasing/orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
|
New purchase order
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
|
<input value={searchTerm} onChange={(event) => setSearchTerm(event.target.value)} placeholder="Search purchase orders by document number or vendor" className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||||
|
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | PurchaseOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{purchaseStatusFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{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-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Document</th>
|
||||||
|
<th className="px-2 py-2">Vendor</th>
|
||||||
|
<th className="px-2 py-2">Status</th>
|
||||||
|
<th className="px-2 py-2">Issue Date</th>
|
||||||
|
<th className="px-2 py-2">Value</th>
|
||||||
|
<th className="px-2 py-2">Lines</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{documents.map((document) => (
|
||||||
|
<tr key={document.id} className="transition hover:bg-page/70">
|
||||||
|
<td className="px-2 py-2"><Link to={`/purchasing/orders/${document.id}`} className="font-semibold text-text hover:text-brand">{document.documentNumber}</Link></td>
|
||||||
|
<td className="px-2 py-2 text-muted">{document.vendorName}</td>
|
||||||
|
<td className="px-2 py-2"><PurchaseStatusBadge status={document.status} /></td>
|
||||||
|
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
7
client/src/modules/purchasing/PurchaseStatusBadge.tsx
Normal file
7
client/src/modules/purchasing/PurchaseStatusBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { PurchaseOrderStatus } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { purchaseStatusPalette } from "./config";
|
||||||
|
|
||||||
|
export function PurchaseStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||||
|
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${purchaseStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
|
||||||
|
}
|
||||||
40
client/src/modules/purchasing/config.ts
Normal file
40
client/src/modules/purchasing/config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { PurchaseOrderInput, PurchaseOrderStatus } from "@mrp/shared";
|
||||||
|
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||||
|
|
||||||
|
export const purchaseStatusOptions: Array<{ value: PurchaseOrderStatus; label: string }> = [
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
{ value: "ISSUED", label: "Issued" },
|
||||||
|
{ value: "APPROVED", label: "Approved" },
|
||||||
|
{ value: "CLOSED", label: "Closed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const purchaseStatusFilters: Array<{ value: "ALL" | PurchaseOrderStatus; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All statuses" },
|
||||||
|
...purchaseStatusOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
|
||||||
|
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
||||||
|
vendorId: "",
|
||||||
|
status: "DRAFT",
|
||||||
|
issueDate: new Date().toISOString(),
|
||||||
|
taxPercent: 0,
|
||||||
|
freightAmount: 0,
|
||||||
|
notes: "",
|
||||||
|
revisionReason: "",
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyPurchaseReceiptInput: PurchaseReceiptInput = {
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
warehouseId: "",
|
||||||
|
locationId: "",
|
||||||
|
notes: "",
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
764
client/src/modules/sales/SalesDetailPage.tsx
Normal file
764
client/src/modules/sales/SalesDetailPage.tsx
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { SalesDocumentDetailDto, SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
import type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||||
|
import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
|
||||||
|
import { SalesStatusBadge } from "./SalesStatusBadge";
|
||||||
|
import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge";
|
||||||
|
|
||||||
|
function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3" style={{ marginLeft: node.level * 12 }}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">
|
||||||
|
{node.itemSku} <span className="text-muted">{node.itemName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
Demand {node.grossDemand} {node.unitOfMeasure} · Type {node.itemType}
|
||||||
|
{node.bomQuantityPerParent !== null ? ` · Qty/parent ${node.bomQuantityPerParent}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted">
|
||||||
|
<div>Linked WO {node.linkedWorkOrderSupply}</div>
|
||||||
|
<div>Linked PO {node.linkedPurchaseSupply}</div>
|
||||||
|
<div>Stock {node.supplyFromStock}</div>
|
||||||
|
<div>Open WO {node.supplyFromOpenWorkOrders}</div>
|
||||||
|
<div>Open PO {node.supplyFromOpenPurchaseOrders}</div>
|
||||||
|
<div>Build {node.recommendedBuildQuantity}</div>
|
||||||
|
<div>Buy {node.recommendedPurchaseQuantity}</div>
|
||||||
|
{node.uncoveredQuantity > 0 ? <div>Uncovered {node.uncoveredQuantity}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{node.children.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<PlanningNodeCard key={`${child.itemId}-${child.level}-${child.itemSku}-${child.grossDemand}`} node={child} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number) {
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSalesDocumentForComparison(
|
||||||
|
document: Pick<
|
||||||
|
SalesDocumentDetailDto,
|
||||||
|
| "documentNumber"
|
||||||
|
| "customerName"
|
||||||
|
| "status"
|
||||||
|
| "issueDate"
|
||||||
|
| "expiresAt"
|
||||||
|
| "approvedAt"
|
||||||
|
| "approvedByName"
|
||||||
|
| "discountAmount"
|
||||||
|
| "discountPercent"
|
||||||
|
| "taxAmount"
|
||||||
|
| "taxPercent"
|
||||||
|
| "freightAmount"
|
||||||
|
| "subtotal"
|
||||||
|
| "total"
|
||||||
|
| "notes"
|
||||||
|
| "lines"
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: document.documentNumber,
|
||||||
|
subtitle: document.customerName,
|
||||||
|
status: document.status,
|
||||||
|
metaFields: [
|
||||||
|
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
|
||||||
|
{ label: "Expires", value: document.expiresAt ? new Date(document.expiresAt).toLocaleDateString() : "N/A" },
|
||||||
|
{ label: "Approval", value: document.approvedAt ? new Date(document.approvedAt).toLocaleDateString() : "Pending" },
|
||||||
|
{ label: "Approver", value: document.approvedByName ?? "No approver recorded" },
|
||||||
|
],
|
||||||
|
totalFields: [
|
||||||
|
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
|
||||||
|
{ label: "Discount", value: `${formatCurrency(document.discountAmount)} (${document.discountPercent.toFixed(2)}%)` },
|
||||||
|
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
|
||||||
|
{ label: "Freight", value: formatCurrency(document.freightAmount) },
|
||||||
|
{ label: "Total", value: formatCurrency(document.total) },
|
||||||
|
],
|
||||||
|
notes: document.notes,
|
||||||
|
lines: document.lines.map((line) => ({
|
||||||
|
key: line.id || `${line.itemId}-${line.position}`,
|
||||||
|
title: `${line.itemSku} | ${line.itemName}`,
|
||||||
|
subtitle: line.description,
|
||||||
|
quantity: `${line.quantity} ${line.unitOfMeasure}`,
|
||||||
|
unitLabel: line.unitOfMeasure,
|
||||||
|
amountLabel: formatCurrency(line.unitPrice),
|
||||||
|
totalLabel: formatCurrency(line.lineTotal),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { quoteId, orderId } = useParams();
|
||||||
|
const config = salesConfigs[entity];
|
||||||
|
const documentId = entity === "quote" ? quoteId : orderId;
|
||||||
|
const [document, setDocument] = useState<SalesDocumentDetailDto | null>(null);
|
||||||
|
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
|
||||||
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
|
const [isConverting, setIsConverting] = useState(false);
|
||||||
|
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
|
||||||
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
|
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||||
|
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "status" | "approve" | "convert";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
nextStatus?: SalesDocumentStatus;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
|
||||||
|
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false;
|
||||||
|
const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false;
|
||||||
|
const canManageManufacturing = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
|
||||||
|
const canManagePurchasing = user?.permissions.includes(permissions.purchasingWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
|
||||||
|
const planningLoader = entity === "order" ? api.getSalesOrderPlanning(token, documentId) : Promise.resolve(null);
|
||||||
|
Promise.all([loader, planningLoader])
|
||||||
|
.then(([nextDocument, nextPlanning]) => {
|
||||||
|
setDocument(nextDocument);
|
||||||
|
setPlanning(nextPlanning);
|
||||||
|
setStatus(`${config.singularLabel} loaded.`);
|
||||||
|
if (entity === "order" && canReadShipping) {
|
||||||
|
api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [canReadShipping, config.singularLabel, documentId, entity, token]);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDocument = document;
|
||||||
|
|
||||||
|
function buildWorkOrderRecommendationLink(itemId: string, quantity: number) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
itemId,
|
||||||
|
salesOrderId: activeDocument.id,
|
||||||
|
quantity: quantity.toString(),
|
||||||
|
status: "DRAFT",
|
||||||
|
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `/manufacturing/work-orders/new?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseRecommendationLink(itemId?: string, vendorId?: string | null) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("planningOrderId", activeDocument.id);
|
||||||
|
if (itemId) {
|
||||||
|
params.set("itemId", itemId);
|
||||||
|
}
|
||||||
|
if (vendorId) {
|
||||||
|
params.set("vendorId", vendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/purchasing/orders/new?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStatusChange(nextStatus: SalesDocumentStatus) {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingStatus(true);
|
||||||
|
setStatus(`Updating ${config.singularLabel.toLowerCase()} status...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextDocument =
|
||||||
|
entity === "quote"
|
||||||
|
? await api.updateQuoteStatus(token, activeDocument.id, nextStatus)
|
||||||
|
: await api.updateSalesOrderStatus(token, activeDocument.id, nextStatus);
|
||||||
|
setDocument(nextDocument);
|
||||||
|
setStatus(`${config.singularLabel} status updated. Review revisions and downstream workflows if the document moved into a terminal or customer-visible state.`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to update ${config.singularLabel.toLowerCase()} status.`;
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyConvert() {
|
||||||
|
if (!token || entity !== "quote") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConverting(true);
|
||||||
|
setStatus("Converting quote to sales order...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order = await api.convertQuoteToSalesOrder(token, activeDocument.id);
|
||||||
|
navigate(`/sales/orders/${order.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to convert quote to sales order.";
|
||||||
|
setStatus(message);
|
||||||
|
setIsConverting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenPdf() {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpeningPdf(true);
|
||||||
|
setStatus(`Rendering ${config.singularLabel.toLowerCase()} PDF...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob =
|
||||||
|
entity === "quote"
|
||||||
|
? await api.getQuotePdf(token, activeDocument.id)
|
||||||
|
: await api.getSalesOrderPdf(token, activeDocument.id);
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
setStatus(`${config.singularLabel} PDF ready.`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to render ${config.singularLabel.toLowerCase()} PDF.`;
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsOpeningPdf(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyApprove() {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApproving(true);
|
||||||
|
setStatus(`Approving ${config.singularLabel.toLowerCase()}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextDocument =
|
||||||
|
entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id);
|
||||||
|
setDocument(nextDocument);
|
||||||
|
setStatus(`${config.singularLabel} approved. The approval stamp is now part of the document history and downstream teams can act on it immediately.`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to approve ${config.singularLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsApproving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(nextStatus: SalesDocumentStatus) {
|
||||||
|
const label = salesStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "status",
|
||||||
|
title: `Set ${config.singularLabel.toLowerCase()} to ${label}`,
|
||||||
|
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
|
||||||
|
impact:
|
||||||
|
nextStatus === "CLOSED"
|
||||||
|
? "This closes the document operationally and can change customer-facing execution assumptions and downstream follow-up expectations."
|
||||||
|
: nextStatus === "APPROVED"
|
||||||
|
? "This marks the document ready for downstream action and becomes part of the approval history."
|
||||||
|
: "This changes the operational state used by downstream workflows and audit/revision history.",
|
||||||
|
recovery: "If this status is set in error, return the document to the correct state and verify the latest revision history.",
|
||||||
|
confirmLabel: `Set ${label}`,
|
||||||
|
confirmationLabel: nextStatus === "CLOSED" ? "Type document number to confirm:" : undefined,
|
||||||
|
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
|
||||||
|
nextStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApprove() {
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "approve",
|
||||||
|
title: `Approve ${config.singularLabel.toLowerCase()}`,
|
||||||
|
description: `Approve ${activeDocument.documentNumber} for ${activeDocument.customerName}.`,
|
||||||
|
impact: "Approval records the approver and timestamp and signals that downstream execution can proceed.",
|
||||||
|
recovery: "If approval was granted by mistake, change the document status and review the revision trail for follow-up.",
|
||||||
|
confirmLabel: "Approve document",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConvert() {
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "convert",
|
||||||
|
title: "Convert quote to sales order",
|
||||||
|
description: `Create a sales order from quote ${activeDocument.documentNumber}.`,
|
||||||
|
impact: "This creates a new sales order record and can trigger planning, purchasing, manufacturing, and shipping follow-up work.",
|
||||||
|
recovery: "Review the new order immediately after creation. If conversion was premature, move the resulting order to the correct status and coordinate with downstream teams.",
|
||||||
|
confirmLabel: "Convert quote",
|
||||||
|
confirmationLabel: "Type quote number to confirm:",
|
||||||
|
confirmationValue: activeDocument.documentNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow}</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
|
||||||
|
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<SalesStatusBadge status={activeDocument.status} />
|
||||||
|
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||||
|
Rev {activeDocument.currentRevisionNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to={config.routeBase} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Back to {config.collectionLabel.toLowerCase()}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenPdf}
|
||||||
|
disabled={isOpeningPdf}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
|
||||||
|
</button>
|
||||||
|
{canManage ? (
|
||||||
|
<>
|
||||||
|
<Link to={`${config.routeBase}/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
|
||||||
|
Edit {config.singularLabel.toLowerCase()}
|
||||||
|
</Link>
|
||||||
|
{activeDocument.status !== "APPROVED" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={isApproving}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
{isApproving ? "Approving..." : "Approve"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{entity === "quote" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConvert}
|
||||||
|
disabled={isConverting}
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
{isConverting ? "Converting..." : "Convert to sales order"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{entity === "order" && canManageShipping ? (
|
||||||
|
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
New shipment
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Update document status without opening the full editor.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{salesStatusOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStatusChange(option.value)}
|
||||||
|
disabled={isUpdatingStatus || activeDocument.status === option.value}
|
||||||
|
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="grid gap-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">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">Expires</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{activeDocument.expiresAt ? new Date(activeDocument.expiresAt).toLocaleDateString() : "N/A"}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[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="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">Approval</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<article className="rounded-[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">Discount</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">-${activeDocument.discountAmount.toFixed(2)}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{activeDocument.discountPercent.toFixed(2)}%</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[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="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="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>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Revision History</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Automatic snapshots are recorded when the document changes status, content, or approval state.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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">
|
||||||
|
No revisions have been recorded yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{activeDocument.revisions.map((revision) => (
|
||||||
|
<article key={revision.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
|
||||||
|
<div className="mt-1 text-sm text-text">{revision.reason}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted">
|
||||||
|
<div>{new Date(revision.createdAt).toLocaleString()}</div>
|
||||||
|
<div className="mt-1">{revision.createdByName ?? "System"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{activeDocument.revisions.length > 0 ? (
|
||||||
|
<DocumentRevisionComparison
|
||||||
|
title="Revision Comparison"
|
||||||
|
description="Compare a prior revision against the current document or another revision to see commercial and line-level changes."
|
||||||
|
currentLabel="Current document"
|
||||||
|
currentDocument={mapSalesDocumentForComparison(activeDocument)}
|
||||||
|
revisions={activeDocument.revisions.map((revision) => ({
|
||||||
|
id: revision.id,
|
||||||
|
label: `Rev ${revision.revisionNumber}`,
|
||||||
|
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
|
||||||
|
}))}
|
||||||
|
getRevisionDocument={(revisionId) => {
|
||||||
|
if (revisionId === "current") {
|
||||||
|
return mapSalesDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
|
||||||
|
if (!revision) {
|
||||||
|
return mapSalesDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapSalesDocumentForComparison({
|
||||||
|
...revision.snapshot,
|
||||||
|
lines: revision.snapshot.lines.map((line) => ({
|
||||||
|
id: `${line.itemId}-${line.position}`,
|
||||||
|
...line,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer</p>
|
||||||
|
<dl className="mt-5 grid gap-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt>
|
||||||
|
<dd className="mt-1 text-sm text-text">{activeDocument.customerName}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
|
||||||
|
<dd className="mt-1 text-sm text-text">{activeDocument.customerEmail}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||||
|
{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-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Item</th>
|
||||||
|
<th className="px-2 py-2">Description</th>
|
||||||
|
<th className="px-2 py-2">Qty</th>
|
||||||
|
<th className="px-2 py-2">UOM</th>
|
||||||
|
<th className="px-2 py-2">Unit Price</th>
|
||||||
|
<th className="px-2 py-2">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{activeDocument.lines.map((line: SalesDocumentDetailDto["lines"][number]) => (
|
||||||
|
<tr key={line.id}>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<div className="font-semibold text-text">{line.itemSku}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.description}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.quantity}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">${line.unitPrice.toFixed(2)}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{entity === "order" && planning ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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 className="text-right text-xs text-muted">
|
||||||
|
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
|
||||||
|
<div>Status {planning.status}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p>
|
||||||
|
<div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Item</th>
|
||||||
|
<th className="px-2 py-2">Gross</th>
|
||||||
|
<th className="px-2 py-2">Linked WO</th>
|
||||||
|
<th className="px-2 py-2">Linked PO</th>
|
||||||
|
<th className="px-2 py-2">Available</th>
|
||||||
|
<th className="px-2 py-2">Open WO</th>
|
||||||
|
<th className="px-2 py-2">Open PO</th>
|
||||||
|
<th className="px-2 py-2">Build</th>
|
||||||
|
<th className="px-2 py-2">Buy</th>
|
||||||
|
<th className="px-2 py-2">Uncovered</th>
|
||||||
|
<th className="px-2 py-2">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{planning.items.map((item) => (
|
||||||
|
<tr key={item.itemId}>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<div className="font-semibold text-text">{item.itemSku}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.grossDemand}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.linkedWorkOrderSupply}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.linkedPurchaseSupply}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.availableQuantity}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.openWorkOrderSupply}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.openPurchaseSupply}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.recommendedBuildQuantity}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.recommendedPurchaseQuantity}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{item.uncoveredQuantity}</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canManageManufacturing && item.recommendedBuildQuantity > 0 ? (
|
||||||
|
<Link
|
||||||
|
to={buildWorkOrderRecommendationLink(item.itemId, item.recommendedBuildQuantity)}
|
||||||
|
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
|
||||||
|
>
|
||||||
|
Draft WO
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{canManagePurchasing && item.recommendedPurchaseQuantity > 0 ? (
|
||||||
|
<Link
|
||||||
|
to={buildPurchaseRecommendationLink(item.itemId)}
|
||||||
|
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
|
||||||
|
>
|
||||||
|
Draft PO
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{canManagePurchasing && planning.summary.purchaseRecommendationCount > 0 ? (
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Link to={buildPurchaseRecommendationLink()} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
Draft purchase order from recommendations
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{planning.lines.map((line) => (
|
||||||
|
<div key={line.lineId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="font-semibold text-text">
|
||||||
|
{line.itemSku} <span className="text-muted">{line.itemName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
Sales-order line demand: {line.quantity} {line.unitOfMeasure}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PlanningNodeCard node={line.rootNode} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{entity === "order" && canReadShipping ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Shipment records currently tied to this sales order.</p>
|
||||||
|
</div>
|
||||||
|
{canManageShipping ? (
|
||||||
|
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Create shipment
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{shipments.length === 0 ? (
|
||||||
|
<div className="mt-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 created for this sales order yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{shipments.map((shipment) => (
|
||||||
|
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{shipment.carrier || "Carrier not set"} · {shipment.trackingNumber || "No tracking"}</div>
|
||||||
|
</div>
|
||||||
|
<ShipmentStatusBadge status={shipment.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm sales action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={
|
||||||
|
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||||
|
(pendingConfirmation?.kind === "approve" && isApproving) ||
|
||||||
|
(pendingConfirmation?.kind === "convert" && isConverting)
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isUpdatingStatus && !isApproving && !isConverting) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
|
||||||
|
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "approve") {
|
||||||
|
await applyApprove();
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "convert") {
|
||||||
|
await applyConvert();
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
502
client/src/modules/sales/SalesFormPage.tsx
Normal file
502
client/src/modules/sales/SalesFormPage.tsx
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
|
import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { inventoryUnitOptions } from "../inventory/config";
|
||||||
|
import { emptySalesDocumentInput, salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
|
||||||
|
|
||||||
|
export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; mode: "create" | "edit" }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { quoteId, orderId } = useParams();
|
||||||
|
const documentId = entity === "quote" ? quoteId : orderId;
|
||||||
|
const config = salesConfigs[entity];
|
||||||
|
const [form, setForm] = useState<SalesDocumentInput>(emptySalesDocumentInput);
|
||||||
|
const [status, setStatus] = useState(mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()}.` : `Loading ${config.singularLabel.toLowerCase()}...`);
|
||||||
|
const [customers, setCustomers] = useState<SalesCustomerOptionDto[]>([]);
|
||||||
|
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
|
||||||
|
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
|
||||||
|
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
|
||||||
|
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
|
||||||
|
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const subtotal = form.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
|
||||||
|
const discountAmount = subtotal * (form.discountPercent / 100);
|
||||||
|
const taxableSubtotal = subtotal - discountAmount;
|
||||||
|
const taxAmount = taxableSubtotal * (form.taxPercent / 100);
|
||||||
|
const total = taxableSubtotal + taxAmount + form.freightAmount;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getSalesCustomers(token).then(setCustomers).catch(() => setCustomers([]));
|
||||||
|
api.getInventoryItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || mode !== "edit" || !documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
|
||||||
|
loader
|
||||||
|
.then((document) => {
|
||||||
|
setForm({
|
||||||
|
customerId: document.customerId,
|
||||||
|
status: document.status,
|
||||||
|
issueDate: document.issueDate,
|
||||||
|
expiresAt: entity === "quote" ? document.expiresAt : null,
|
||||||
|
discountPercent: document.discountPercent,
|
||||||
|
taxPercent: document.taxPercent,
|
||||||
|
freightAmount: document.freightAmount,
|
||||||
|
notes: document.notes,
|
||||||
|
revisionReason: "",
|
||||||
|
lines: document.lines.map((line) => ({
|
||||||
|
itemId: line.itemId,
|
||||||
|
description: line.description,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitOfMeasure: line.unitOfMeasure,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
position: line.position,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setCustomerSearchTerm(document.customerName);
|
||||||
|
setLineSearchTerms(document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => line.itemSku));
|
||||||
|
setStatus(`${config.singularLabel} loaded.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [config.singularLabel, documentId, entity, mode, token]);
|
||||||
|
|
||||||
|
function updateField<Key extends keyof SalesDocumentInput>(key: Key, value: SalesDocumentInput[Key]) {
|
||||||
|
setForm((current: SalesDocumentInput) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedCustomerName(customerId: string) {
|
||||||
|
return customers.find((customer) => customer.id === customerId)?.name ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedCustomer(customerId: string) {
|
||||||
|
return customers.find((customer) => customer.id === customerId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(index: number, nextLine: SalesLineInput) {
|
||||||
|
setForm((current: SalesDocumentInput) => ({
|
||||||
|
...current,
|
||||||
|
lines: current.lines.map((line: SalesLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLineSearchTerm(index: number, value: string) {
|
||||||
|
setLineSearchTerms((current: string[]) => {
|
||||||
|
const next = [...current];
|
||||||
|
next[index] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setForm((current: SalesDocumentInput) => ({
|
||||||
|
...current,
|
||||||
|
lines: [
|
||||||
|
...current.lines,
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
description: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitOfMeasure: "EA",
|
||||||
|
unitPrice: 0,
|
||||||
|
position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: SalesLineInput) => line.position)) + 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
setLineSearchTerms((current: string[]) => [...current, ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
setForm((current: SalesDocumentInput) => ({
|
||||||
|
...current,
|
||||||
|
lines: current.lines.filter((_line: SalesLineInput, lineIndex: number) => lineIndex !== index),
|
||||||
|
}));
|
||||||
|
setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingLineRemoval =
|
||||||
|
pendingLineRemovalIndex != null
|
||||||
|
? {
|
||||||
|
index: pendingLineRemovalIndex,
|
||||||
|
line: form.lines[pendingLineRemovalIndex],
|
||||||
|
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved =
|
||||||
|
entity === "quote"
|
||||||
|
? mode === "create"
|
||||||
|
? await api.createQuote(token, form)
|
||||||
|
: await api.updateQuote(token, documentId ?? "", form)
|
||||||
|
: mode === "create"
|
||||||
|
? await api.createSalesOrder(token, { ...form, expiresAt: null })
|
||||||
|
: await api.updateSalesOrder(token, documentId ?? "", { ...form, expiresAt: null });
|
||||||
|
|
||||||
|
navigate(`${config.routeBase}/${saved.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow} Editor</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
|
||||||
|
</div>
|
||||||
|
<Link to={mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<label className="block xl:col-span-2">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={customerSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCustomerSearchTerm(event.target.value);
|
||||||
|
updateField("customerId", "");
|
||||||
|
setCustomerPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setCustomerPickerOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setCustomerPickerOpen(false);
|
||||||
|
if (form.customerId) {
|
||||||
|
setCustomerSearchTerm(getSelectedCustomerName(form.customerId));
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
placeholder="Search customer"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{customerPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
{customers
|
||||||
|
.filter((customer) => {
|
||||||
|
const query = customerSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
customer.name.toLowerCase().includes(query) ||
|
||||||
|
customer.email.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((customer) => (
|
||||||
|
<button
|
||||||
|
key={customer.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("customerId", customer.id);
|
||||||
|
updateField("discountPercent", customer.resellerDiscountPercent);
|
||||||
|
setCustomerSearchTerm(customer.name);
|
||||||
|
setCustomerPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-text">{customer.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{customer.email}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{customers.filter((customer) => {
|
||||||
|
const query = customerSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
|
||||||
|
}).length === 0 ? (
|
||||||
|
<div className="px-2 py-2 text-sm text-muted">No matching customers found.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 min-h-5 text-xs text-muted">
|
||||||
|
{form.customerId ? getSelectedCustomerName(form.customerId) : "No customer selected"}
|
||||||
|
</div>
|
||||||
|
{form.customerId ? (
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
Default reseller discount: {getSelectedCustomer(form.customerId)?.resellerDiscountPercent.toFixed(2) ?? "0.00"}%
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(event) => updateField("status", event.target.value as SalesDocumentInput["status"])}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{salesStatusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Issue date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.issueDate.slice(0, 10)}
|
||||||
|
onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{entity === "quote" ? (
|
||||||
|
<label className="block xl:max-w-sm">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Expiration date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.expiresAt ? form.expiresAt.slice(0, 10) : ""}
|
||||||
|
onChange={(event) => updateField("expiresAt", event.target.value ? new Date(event.target.value).toISOString() : null)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(event) => updateField("notes", event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<label className="block xl:max-w-xl">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Revision Reason</span>
|
||||||
|
<input
|
||||||
|
value={form.revisionReason ?? ""}
|
||||||
|
onChange={(event) => updateField("revisionReason", event.target.value)}
|
||||||
|
placeholder="What changed in this revision?"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Discount %</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
value={form.discountPercent}
|
||||||
|
onChange={(event) => updateField("discountPercent", Number(event.target.value) || 0)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
value={form.taxPercent}
|
||||||
|
onChange={(event) => updateField("taxPercent", Number(event.target.value) || 0)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Freight</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={form.freightAmount}
|
||||||
|
onChange={(event) => updateField("freightAmount", Number(event.target.value) || 0)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
|
||||||
|
<h4 className="mt-2 text-lg font-bold text-text">Commercial lines</h4>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Add line
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.lines.length === 0 ? (
|
||||||
|
<div className="mt-5 rounded-[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-5 space-y-4">
|
||||||
|
{form.lines.map((line: SalesLineInput, index: number) => (
|
||||||
|
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={lineSearchTerms[index] ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
updateLineSearchTerm(index, event.target.value);
|
||||||
|
updateLine(index, { ...line, itemId: "" });
|
||||||
|
setActiveLinePicker(index);
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveLinePicker(index)}
|
||||||
|
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
|
||||||
|
placeholder="Search by SKU"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{activeLinePicker === index ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
{itemOptions
|
||||||
|
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateLine(index, {
|
||||||
|
...line,
|
||||||
|
itemId: option.id,
|
||||||
|
description: line.description || option.name,
|
||||||
|
unitPrice: line.unitPrice > 0 ? line.unitPrice : (option.defaultPrice ?? 0),
|
||||||
|
});
|
||||||
|
updateLineSearchTerm(index, option.sku);
|
||||||
|
setActiveLinePicker(null);
|
||||||
|
}}
|
||||||
|
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
|
||||||
|
>
|
||||||
|
{option.sku}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||||
|
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
|
||||||
|
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
|
||||||
|
<select value={line.unitOfMeasure} onChange={(event) => updateLine(index, { ...line, unitOfMeasure: event.target.value as SalesLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{inventoryUnitOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Unit Price</span>
|
||||||
|
<input type="number" min={0} step={0.01} value={line.unitPrice} onChange={(event) => updateLine(index, { ...line, unitPrice: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">
|
||||||
|
${(line.quantity * line.unitPrice).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Discount</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">-${discountAmount.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Tax + Freight</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">${(taxAmount + form.freightAmount).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-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">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel.toLowerCase()}` : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingLineRemoval != null}
|
||||||
|
title={`Remove ${config.singularLabel.toLowerCase()} line`}
|
||||||
|
description={
|
||||||
|
pendingLineRemoval
|
||||||
|
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the ${config.singularLabel.toLowerCase()}.`
|
||||||
|
: "Remove this line."
|
||||||
|
}
|
||||||
|
impact="The line will be dropped from the document draft immediately and totals will recalculate."
|
||||||
|
recovery="Add the line back manually before saving if this removal was a mistake."
|
||||||
|
confirmLabel="Remove line"
|
||||||
|
isConfirming={false}
|
||||||
|
onClose={() => setPendingLineRemovalIndex(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingLineRemoval) {
|
||||||
|
removeLine(pendingLineRemoval.index);
|
||||||
|
}
|
||||||
|
setPendingLineRemovalIndex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
130
client/src/modules/sales/SalesListPage.tsx
Normal file
130
client/src/modules/sales/SalesListPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { SalesDocumentStatus, SalesDocumentSummaryDto } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { salesConfigs, salesStatusFilters, type SalesDocumentEntity } from "./config";
|
||||||
|
import { SalesStatusBadge } from "./SalesStatusBadge";
|
||||||
|
|
||||||
|
export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const config = salesConfigs[entity];
|
||||||
|
const [documents, setDocuments] = useState<SalesDocumentSummaryDto[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | SalesDocumentStatus>("ALL");
|
||||||
|
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader =
|
||||||
|
entity === "quote"
|
||||||
|
? api.getQuotes(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
|
||||||
|
: api.getSalesOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter });
|
||||||
|
|
||||||
|
loader
|
||||||
|
.then((nextDocuments) => {
|
||||||
|
setDocuments(nextDocuments);
|
||||||
|
setStatus(`${nextDocuments.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.listEyebrow}</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">
|
||||||
|
Customer-facing commercial documents for pricing, commitment, and downstream fulfillment planning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`${config.routeBase}/new`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
|
New {config.singularLabel.toLowerCase()}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder={`Search ${config.collectionLabel.toLowerCase()} by document number or customer`}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value as "ALL" | SalesDocumentStatus)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{salesStatusFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{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 {config.collectionLabel.toLowerCase()} have been added yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Document</th>
|
||||||
|
<th className="px-2 py-2">Customer</th>
|
||||||
|
<th className="px-2 py-2">Status</th>
|
||||||
|
<th className="px-2 py-2">Revision</th>
|
||||||
|
<th className="px-2 py-2">Issue Date</th>
|
||||||
|
<th className="px-2 py-2">Value</th>
|
||||||
|
<th className="px-2 py-2">Lines</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{documents.map((document) => (
|
||||||
|
<tr key={document.id} className="transition hover:bg-page/70">
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<Link to={`${config.routeBase}/${document.id}`} className="font-semibold text-text hover:text-brand">
|
||||||
|
{document.documentNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{document.customerName}</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<SalesStatusBadge status={document.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">
|
||||||
|
Rev {document.currentRevisionNumber}
|
||||||
|
{document.approvedAt ? <div className="mt-1 text-xs text-muted">Approved</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
9
client/src/modules/sales/SalesStatusBadge.tsx
Normal file
9
client/src/modules/sales/SalesStatusBadge.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
|
||||||
|
import { salesStatusOptions, salesStatusPalette } from "./config";
|
||||||
|
|
||||||
|
export function SalesStatusBadge({ status }: { status: SalesDocumentStatus }) {
|
||||||
|
const label = salesStatusOptions.find((option) => option.value === status)?.label ?? status;
|
||||||
|
|
||||||
|
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${salesStatusPalette[status]}`}>{label}</span>;
|
||||||
|
}
|
||||||
63
client/src/modules/sales/config.ts
Normal file
63
client/src/modules/sales/config.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { SalesDocumentInput, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
|
||||||
|
|
||||||
|
export type SalesDocumentEntity = "quote" | "order";
|
||||||
|
|
||||||
|
interface SalesModuleConfig {
|
||||||
|
entity: SalesDocumentEntity;
|
||||||
|
singularLabel: string;
|
||||||
|
collectionLabel: string;
|
||||||
|
routeBase: string;
|
||||||
|
detailEyebrow: string;
|
||||||
|
listEyebrow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesConfigs: Record<SalesDocumentEntity, SalesModuleConfig> = {
|
||||||
|
quote: {
|
||||||
|
entity: "quote",
|
||||||
|
singularLabel: "Quote",
|
||||||
|
collectionLabel: "Quotes",
|
||||||
|
routeBase: "/sales/quotes",
|
||||||
|
detailEyebrow: "Sales Quote",
|
||||||
|
listEyebrow: "Sales",
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
entity: "order",
|
||||||
|
singularLabel: "Sales Order",
|
||||||
|
collectionLabel: "Sales Orders",
|
||||||
|
routeBase: "/sales/orders",
|
||||||
|
detailEyebrow: "Sales Order",
|
||||||
|
listEyebrow: "Sales",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const salesStatusOptions: Array<{ value: SalesDocumentStatus; label: string }> = [
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
{ value: "ISSUED", label: "Issued" },
|
||||||
|
{ value: "APPROVED", label: "Approved" },
|
||||||
|
{ value: "CLOSED", label: "Closed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const salesStatusFilters: Array<{ value: "ALL" | SalesDocumentStatus; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All statuses" },
|
||||||
|
...salesStatusOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const salesStatusPalette: Record<SalesDocumentStatus, string> = {
|
||||||
|
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptySalesDocumentInput: SalesDocumentInput = {
|
||||||
|
customerId: "",
|
||||||
|
status: "DRAFT",
|
||||||
|
issueDate: new Date().toISOString(),
|
||||||
|
expiresAt: null,
|
||||||
|
discountPercent: 0,
|
||||||
|
taxPercent: 0,
|
||||||
|
freightAmount: 0,
|
||||||
|
notes: "",
|
||||||
|
lines: [],
|
||||||
|
revisionReason: "",
|
||||||
|
};
|
||||||
457
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
457
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto } from "@mrp/shared";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(metadataJson: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(metadataJson) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDiagnosticsPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
|
||||||
|
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
|
||||||
|
const [supportLogData, setSupportLogData] = useState<SupportLogListDto | null>(null);
|
||||||
|
const [status, setStatus] = useState("Loading diagnostics...");
|
||||||
|
const [supportLogLevel, setSupportLogLevel] = useState<"ALL" | SupportLogEntryDto["level"]>("ALL");
|
||||||
|
const [supportLogSource, setSupportLogSource] = useState("ALL");
|
||||||
|
const [supportLogQuery, setSupportLogQuery] = useState("");
|
||||||
|
const [supportLogWindowDays, setSupportLogWindowDays] = useState<"ALL" | "1" | "7" | "14">("ALL");
|
||||||
|
|
||||||
|
function buildSupportLogFilters(): SupportLogFiltersDto {
|
||||||
|
const now = new Date();
|
||||||
|
const start =
|
||||||
|
supportLogWindowDays === "ALL"
|
||||||
|
? undefined
|
||||||
|
: new Date(now.getTime() - Number.parseInt(supportLogWindowDays, 10) * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: supportLogLevel === "ALL" ? undefined : supportLogLevel,
|
||||||
|
source: supportLogSource === "ALL" ? undefined : supportLogSource,
|
||||||
|
query: supportLogQuery.trim() || undefined,
|
||||||
|
start,
|
||||||
|
limit: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token, buildSupportLogFilters())])
|
||||||
|
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDiagnostics(nextDiagnostics);
|
||||||
|
setBackupGuidance(nextBackupGuidance);
|
||||||
|
setSupportLogData(nextSupportLogs);
|
||||||
|
setStatus("Diagnostics loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(error.message || "Unable to load diagnostics.");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
||||||
|
|
||||||
|
if (!diagnostics || !backupGuidance) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportSupportSnapshot() {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await api.getSupportSnapshotWithFilters(token, buildSupportLogFilters());
|
||||||
|
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = `mrp-codex-support-snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||||
|
link.click();
|
||||||
|
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
setStatus("Support snapshot exported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportSupportLogs() {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await api.getSupportLogs(token, buildSupportLogFilters());
|
||||||
|
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" });
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||||
|
link.click();
|
||||||
|
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
setSupportLogData(logs);
|
||||||
|
setStatus("Support logs exported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportLogs = supportLogData?.entries ?? [];
|
||||||
|
const supportLogSummary = supportLogData?.summary;
|
||||||
|
const supportLogSources = supportLogData?.availableSources ?? [];
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
["Server time", formatDateTime(diagnostics.serverTime)],
|
||||||
|
["Node runtime", diagnostics.nodeVersion],
|
||||||
|
["Audit events", diagnostics.auditEventCount.toString()],
|
||||||
|
["Support logs", diagnostics.supportLogCount.toString()],
|
||||||
|
["Retention", `${supportLogSummary?.retentionDays ?? 0} days`],
|
||||||
|
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
|
||||||
|
["Sessions to review", diagnostics.reviewSessionCount.toString()],
|
||||||
|
["Sales docs", diagnostics.salesDocumentCount.toString()],
|
||||||
|
["Work orders", diagnostics.workOrderCount.toString()],
|
||||||
|
["Projects", diagnostics.projectCount.toString()],
|
||||||
|
["Attachments", diagnostics.attachmentCount.toString()],
|
||||||
|
];
|
||||||
|
|
||||||
|
const footprintCards = [
|
||||||
|
["Database URL", diagnostics.databaseUrl],
|
||||||
|
["Data directory", diagnostics.dataDir],
|
||||||
|
["Uploads directory", diagnostics.uploadsDir],
|
||||||
|
["Client origin", diagnostics.clientOrigin],
|
||||||
|
["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"],
|
||||||
|
["Active sessions", diagnostics.activeSessionCount.toString()],
|
||||||
|
["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`],
|
||||||
|
["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`],
|
||||||
|
["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`],
|
||||||
|
["Purchase orders", diagnostics.purchaseOrderCount.toString()],
|
||||||
|
["Shipments", diagnostics.shipmentCount.toString()],
|
||||||
|
];
|
||||||
|
|
||||||
|
const startupStatusTone =
|
||||||
|
diagnostics.startup.status === "PASS"
|
||||||
|
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200"
|
||||||
|
: diagnostics.startup.status === "WARN"
|
||||||
|
? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200"
|
||||||
|
: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200";
|
||||||
|
|
||||||
|
const startupSummaryCards = [
|
||||||
|
["Generated", formatDateTime(diagnostics.startup.generatedAt)],
|
||||||
|
["Duration", `${diagnostics.startup.durationMs} ms`],
|
||||||
|
["Pass / Warn / Fail", `${diagnostics.startup.passCount} / ${diagnostics.startup.warnCount} / ${diagnostics.startup.failCount}`],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin Diagnostics</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Operational runtime and audit visibility</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 className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportSupportSnapshot}
|
||||||
|
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
|
||||||
|
>
|
||||||
|
Export support bundle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportSupportLogs}
|
||||||
|
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
|
||||||
|
>
|
||||||
|
Export support logs
|
||||||
|
</button>
|
||||||
|
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
User management
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
Company settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{summaryCards.map(([label, value]) => (
|
||||||
|
<div key={label} className="rounded-[18px] border border-line/70 bg-page/70 p-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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 className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||||
|
<div>Data: {backupGuidance.dataPath}</div>
|
||||||
|
<div>DB: {backupGuidance.databasePath}</div>
|
||||||
|
<div>Uploads: {backupGuidance.uploadsPath}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
||||||
|
<p className="text-sm font-semibold text-text">Backup checklist</p>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{backupGuidance.backupSteps.map((step) => (
|
||||||
|
<div key={step.id}>
|
||||||
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">{step.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
||||||
|
<p className="text-sm font-semibold text-text">Restore checklist</p>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{backupGuidance.restoreSteps.map((step) => (
|
||||||
|
<div key={step.id}>
|
||||||
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">{step.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
||||||
|
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{backupGuidance.verificationChecklist.map((item) => (
|
||||||
|
<div key={item.id}>
|
||||||
|
<p className="text-sm font-semibold text-text">{item.label}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">{item.detail}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">Evidence: {item.evidence}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
||||||
|
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{backupGuidance.restoreDrillSteps.map((step) => (
|
||||||
|
<div key={step.id}>
|
||||||
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">{step.detail}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">Expected outcome: {step.expectedOutcome}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Startup Validation</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
|
||||||
|
{diagnostics.startup.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
{diagnostics.startup.checks.map((check) => (
|
||||||
|
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-semibold text-text">{check.label}</p>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{check.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted">{check.message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 lg:grid-cols-3">
|
||||||
|
{startupSummaryCards.map(([label, value]) => (
|
||||||
|
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
|
<p className="mt-2 text-sm text-text">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
{footprintCards.map(([label, value]) => (
|
||||||
|
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
|
<p className="mt-2 break-all text-sm text-text">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Support Logs</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
||||||
|
<input
|
||||||
|
value={supportLogQuery}
|
||||||
|
onChange={(event) => setSupportLogQuery(event.target.value)}
|
||||||
|
placeholder="Message, source, context"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Level</span>
|
||||||
|
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
|
<option value="ALL">All levels</option>
|
||||||
|
<option value="ERROR">Error</option>
|
||||||
|
<option value="WARN">Warn</option>
|
||||||
|
<option value="INFO">Info</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Source</span>
|
||||||
|
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
|
<option value="ALL">All sources</option>
|
||||||
|
{supportLogSources.map((source) => (
|
||||||
|
<option key={source} value={source}>{source}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Window</span>
|
||||||
|
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
|
<option value="ALL">All retained</option>
|
||||||
|
<option value="1">Last 24 hours</option>
|
||||||
|
<option value="7">Last 7 days</option>
|
||||||
|
<option value="14">Last 14 days</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||||
|
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
|
||||||
|
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
|
||||||
|
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
<th className="px-3 py-3">When</th>
|
||||||
|
<th className="px-3 py-3">Level</th>
|
||||||
|
<th className="px-3 py-3">Source</th>
|
||||||
|
<th className="px-3 py-3">Message</th>
|
||||||
|
<th className="px-3 py-3">Context</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70">
|
||||||
|
{supportLogs.map((entry) => {
|
||||||
|
const context = parseMetadata(entry.contextJson);
|
||||||
|
return (
|
||||||
|
<tr key={entry.id} className="align-top">
|
||||||
|
<td className="px-3 py-3 text-muted">{formatDateTime(entry.createdAt)}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
|
||||||
|
{entry.level}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-text">{entry.source}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{entry.message}</td>
|
||||||
|
<td className="px-3 py-3 text-xs text-muted">
|
||||||
|
{Object.keys(context).length > 0 ? JSON.stringify(context) : "No context"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{supportLogs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-sm text-muted">
|
||||||
|
No support logs matched the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Audit Trail</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Latest cross-module write activity</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">{status}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
<th className="px-3 py-3">When</th>
|
||||||
|
<th className="px-3 py-3">Actor</th>
|
||||||
|
<th className="px-3 py-3">Entity</th>
|
||||||
|
<th className="px-3 py-3">Action</th>
|
||||||
|
<th className="px-3 py-3">Summary</th>
|
||||||
|
<th className="px-3 py-3">Metadata</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70">
|
||||||
|
{diagnostics.recentAuditEvents.map((event) => {
|
||||||
|
const metadata = parseMetadata(event.metadataJson);
|
||||||
|
return (
|
||||||
|
<tr key={event.id} className="align-top">
|
||||||
|
<td className="px-3 py-3 text-muted">{formatDateTime(event.createdAt)}</td>
|
||||||
|
<td className="px-3 py-3 text-text">{event.actorName ?? "System"}</td>
|
||||||
|
<td className="px-3 py-3 text-text">
|
||||||
|
<div>{event.entityType}</div>
|
||||||
|
{event.entityId ? <div className="text-xs text-muted">{event.entityId}</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
|
||||||
|
{event.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-text">{event.summary}</td>
|
||||||
|
<td className="px-3 py-3 text-xs text-muted">
|
||||||
|
{Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "No metadata"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
248
client/src/modules/settings/CompanySettingsPage.tsx
Normal file
248
client/src/modules/settings/CompanySettingsPage.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import type { CompanyProfileInput } from "@mrp/shared";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { useTheme } from "../../theme/ThemeProvider";
|
||||||
|
|
||||||
|
export function CompanySettingsPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { applyBrandProfile } = useTheme();
|
||||||
|
const [form, setForm] = useState<CompanyProfileInput | null>(null);
|
||||||
|
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||||
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<string>("Loading company profile...");
|
||||||
|
|
||||||
|
async function loadLogoPreview(nextToken: string, logoFileId: string | null) {
|
||||||
|
if (!logoFileId) {
|
||||||
|
setLogoUrl((current) => {
|
||||||
|
if (current?.startsWith("blob:")) {
|
||||||
|
window.URL.revokeObjectURL(current);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await api.getFileContentBlob(nextToken, logoFileId);
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
setLogoUrl((current) => {
|
||||||
|
if (current?.startsWith("blob:")) {
|
||||||
|
window.URL.revokeObjectURL(current);
|
||||||
|
}
|
||||||
|
return objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
api.getCompanyProfile(token).then((profile) => {
|
||||||
|
setCompanyId(profile.id);
|
||||||
|
setForm({
|
||||||
|
companyName: profile.companyName,
|
||||||
|
legalName: profile.legalName,
|
||||||
|
email: profile.email,
|
||||||
|
phone: profile.phone,
|
||||||
|
website: profile.website,
|
||||||
|
taxId: profile.taxId,
|
||||||
|
addressLine1: profile.addressLine1,
|
||||||
|
addressLine2: profile.addressLine2,
|
||||||
|
city: profile.city,
|
||||||
|
state: profile.state,
|
||||||
|
postalCode: profile.postalCode,
|
||||||
|
country: profile.country,
|
||||||
|
theme: profile.theme,
|
||||||
|
});
|
||||||
|
applyBrandProfile(profile);
|
||||||
|
setStatus("Company profile loaded.");
|
||||||
|
|
||||||
|
if (profile.theme.logoFileId) {
|
||||||
|
loadLogoPreview(token, profile.theme.logoFileId)
|
||||||
|
.then(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setLogoUrl(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLogoUrl(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [applyBrandProfile, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (logoUrl?.startsWith("blob:")) {
|
||||||
|
window.URL.revokeObjectURL(logoUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [logoUrl]);
|
||||||
|
|
||||||
|
if (!form || !token) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token || !form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profile = await api.updateCompanyProfile(token, form);
|
||||||
|
applyBrandProfile(profile);
|
||||||
|
await loadLogoPreview(token, profile.theme.logoFileId);
|
||||||
|
setStatus("Company settings saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogoUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file || !companyId || !token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await api.uploadFile(token, file, "company-profile", companyId);
|
||||||
|
setForm((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
theme: {
|
||||||
|
...current.theme,
|
||||||
|
logoFileId: attachment.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
await loadLogoPreview(token, attachment.id);
|
||||||
|
setStatus("Logo uploaded. Save to persist it on the profile.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePdfPreview() {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await api.getCompanyProfilePreviewPdf(token);
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||||
|
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField<Key extends keyof CompanyProfileInput>(key: Key, value: CompanyProfileInput[Key]) {
|
||||||
|
setForm((current) => (current ? { ...current, [key]: value } : current));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSave}>
|
||||||
|
{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">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
User management
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
Open diagnostics
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Branding and legal identity</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">Every internal document and PDF template will inherit its company identity from this profile.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/80 p-4">
|
||||||
|
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
|
||||||
|
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
|
||||||
|
Upload logo
|
||||||
|
<input className="hidden" type="file" accept="image/*" onChange={handleLogoUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{[
|
||||||
|
["companyName", "Company name"],
|
||||||
|
["legalName", "Legal name"],
|
||||||
|
["email", "Email"],
|
||||||
|
["phone", "Phone"],
|
||||||
|
["website", "Website"],
|
||||||
|
["taxId", "Tax ID"],
|
||||||
|
["addressLine1", "Address line 1"],
|
||||||
|
["addressLine2", "Address line 2"],
|
||||||
|
["city", "City"],
|
||||||
|
["state", "State"],
|
||||||
|
["postalCode", "Postal code"],
|
||||||
|
["country", "Country"],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<label key={key} className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">{label}</span>
|
||||||
|
<input
|
||||||
|
value={String(form[key as keyof CompanyProfileInput])}
|
||||||
|
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Primary color</span>
|
||||||
|
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Accent color</span>
|
||||||
|
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Surface color</span>
|
||||||
|
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Font family</span>
|
||||||
|
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePdfPreview}
|
||||||
|
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
||||||
|
>
|
||||||
|
Preview PDF
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
677
client/src/modules/settings/UserManagementPage.tsx
Normal file
677
client/src/modules/settings/UserManagementPage.tsx
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
import type {
|
||||||
|
AdminAuthSessionDto,
|
||||||
|
AdminPermissionOptionDto,
|
||||||
|
AdminRoleDto,
|
||||||
|
AdminRoleInput,
|
||||||
|
AdminUserDto,
|
||||||
|
AdminUserInput,
|
||||||
|
} from "@mrp/shared";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
|
||||||
|
const emptyUserForm: AdminUserInput = {
|
||||||
|
email: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
isActive: true,
|
||||||
|
roleIds: [],
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyRoleForm: AdminRoleInput = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
permissionKeys: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserManagementPage() {
|
||||||
|
const { token, user: authUser, logout } = useAuth();
|
||||||
|
const [users, setUsers] = useState<AdminUserDto[]>([]);
|
||||||
|
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<AdminAuthSessionDto[]>([]);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string>("new");
|
||||||
|
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
|
||||||
|
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
|
||||||
|
const [sessionStatusFilter, setSessionStatusFilter] = useState<"ALL" | AdminAuthSessionDto["status"]>("ALL");
|
||||||
|
const [sessionReviewFilter, setSessionReviewFilter] = useState<"ALL" | AdminAuthSessionDto["reviewState"]>("ALL");
|
||||||
|
const [sessionQuery, setSessionQuery] = useState("");
|
||||||
|
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
|
||||||
|
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
|
||||||
|
const [status, setStatus] = useState("Loading admin access controls...");
|
||||||
|
const [isConfirmingAction, setIsConfirmingAction] = useState(false);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
kind: "deactivate-user" | "revoke-session";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
userId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token), api.getAdminSessions(token)])
|
||||||
|
.then(([nextUsers, nextRoles, nextPermissions, nextSessions]) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsers(nextUsers);
|
||||||
|
setRoles(nextRoles);
|
||||||
|
setPermissions(nextPermissions);
|
||||||
|
setSessions(nextSessions);
|
||||||
|
setStatus("User management loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(error.message || "Unable to load admin access controls.");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedUserId === "new") {
|
||||||
|
setUserForm(emptyUserForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedUser = users.find((user) => user.id === selectedUserId);
|
||||||
|
if (!selectedUser) {
|
||||||
|
setUserForm(emptyUserForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserForm({
|
||||||
|
email: selectedUser.email,
|
||||||
|
firstName: selectedUser.firstName,
|
||||||
|
lastName: selectedUser.lastName,
|
||||||
|
isActive: selectedUser.isActive,
|
||||||
|
roleIds: selectedUser.roleIds,
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
}, [selectedUserId, users]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRoleId === "new") {
|
||||||
|
setRoleForm(emptyRoleForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRole = roles.find((role) => role.id === selectedRoleId);
|
||||||
|
if (!selectedRole) {
|
||||||
|
setRoleForm(emptyRoleForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoleForm({
|
||||||
|
name: selectedRole.name,
|
||||||
|
description: selectedRole.description,
|
||||||
|
permissionKeys: selectedRole.permissionKeys,
|
||||||
|
});
|
||||||
|
}, [roles, selectedRoleId]);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = token;
|
||||||
|
|
||||||
|
async function refreshData(nextStatus: string) {
|
||||||
|
const [nextUsers, nextRoles, nextPermissions, nextSessions] = await Promise.all([
|
||||||
|
api.getAdminUsers(authToken),
|
||||||
|
api.getAdminRoles(authToken),
|
||||||
|
api.getAdminPermissions(authToken),
|
||||||
|
api.getAdminSessions(authToken),
|
||||||
|
]);
|
||||||
|
setUsers(nextUsers);
|
||||||
|
setRoles(nextRoles);
|
||||||
|
setPermissions(nextPermissions);
|
||||||
|
setSessions(nextSessions);
|
||||||
|
setStatus(nextStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const selectedUser = users.find((entry) => entry.id === selectedUserId);
|
||||||
|
if (selectedUser && selectedUser.isActive && !userForm.isActive) {
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "deactivate-user",
|
||||||
|
title: `Deactivate ${selectedUser.firstName} ${selectedUser.lastName}`,
|
||||||
|
description: `Disable sign-in for ${selectedUser.email}. Existing active sessions will remain revoked only if you separately revoke them below.`,
|
||||||
|
impact: "The user will be blocked from new sign-ins as soon as this save completes.",
|
||||||
|
recovery: "Re-enable the account later if the change was made in error, and revoke live sessions separately if immediate cut-off is required.",
|
||||||
|
confirmLabel: "Deactivate user",
|
||||||
|
confirmationLabel: "Type user email to confirm:",
|
||||||
|
confirmationValue: selectedUser.email,
|
||||||
|
userId: selectedUser.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveUser();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setStatus(error instanceof Error ? error.message : "Unable to save user.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
const normalizedUserForm: AdminUserInput = {
|
||||||
|
...userForm,
|
||||||
|
password: userForm.password && userForm.password.trim().length > 0 ? userForm.password : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedUserId === "new") {
|
||||||
|
const createdUser = await api.createAdminUser(authToken, normalizedUserForm);
|
||||||
|
await refreshData(`Created user ${createdUser.email}.`);
|
||||||
|
setSelectedUserId(createdUser.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await api.updateAdminUser(authToken, selectedUserId, normalizedUserForm);
|
||||||
|
await refreshData(`Updated user ${updatedUser.email}.`);
|
||||||
|
setSelectedUserId(updatedUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
if (selectedRoleId === "new") {
|
||||||
|
const createdRole = await api.createAdminRole(authToken, roleForm);
|
||||||
|
await refreshData(`Created role ${createdRole.name}.`);
|
||||||
|
setSelectedRoleId(createdRole.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm);
|
||||||
|
await refreshData(`Updated role ${updatedRole.name}.`);
|
||||||
|
setSelectedRoleId(updatedRole.id);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setStatus(error instanceof Error ? error.message : "Unable to save role.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserRole(roleId: string) {
|
||||||
|
setUserForm((current) => ({
|
||||||
|
...current,
|
||||||
|
roleIds: current.roleIds.includes(roleId)
|
||||||
|
? current.roleIds.filter((currentRoleId) => currentRoleId !== roleId)
|
||||||
|
: [...current.roleIds, roleId],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRolePermission(permissionKey: string) {
|
||||||
|
setRoleForm((current) => ({
|
||||||
|
...current,
|
||||||
|
permissionKeys: current.permissionKeys.includes(permissionKey)
|
||||||
|
? current.permissionKeys.filter((currentPermissionKey) => currentPermissionKey !== permissionKey)
|
||||||
|
: [...current.permissionKeys, permissionKey],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSessionRevoke(sessionId: string, isCurrentSession: boolean) {
|
||||||
|
await api.revokeAdminSession(authToken, sessionId);
|
||||||
|
if (isCurrentSession) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshData("Revoked session. The user must sign in again to restore access unless their account is inactive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSessionQuery = sessionQuery.trim().toLowerCase();
|
||||||
|
const filteredSessions = sessions.filter((session) => {
|
||||||
|
if (sessionUserFilter !== "all" && session.userId !== sessionUserFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionStatusFilter !== "ALL" && session.status !== sessionStatusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionReviewFilter !== "ALL" && session.reviewState !== sessionReviewFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedSessionQuery) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.userName.toLowerCase().includes(normalizedSessionQuery) ||
|
||||||
|
session.userEmail.toLowerCase().includes(normalizedSessionQuery) ||
|
||||||
|
(session.ipAddress ?? "").toLowerCase().includes(normalizedSessionQuery) ||
|
||||||
|
(session.userAgent ?? "").toLowerCase().includes(normalizedSessionQuery) ||
|
||||||
|
session.reviewReasons.some((reason) => reason.toLowerCase().includes(normalizedSessionQuery))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const activeSessionCount = sessions.filter((session) => session.status === "ACTIVE").length;
|
||||||
|
const revokedSessionCount = sessions.filter((session) => session.status === "REVOKED").length;
|
||||||
|
const expiredSessionCount = sessions.filter((session) => session.status === "EXPIRED").length;
|
||||||
|
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">User Management</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Accounts, roles, and permission assignment</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 className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
Company settings
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
|
Diagnostics
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 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}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Users</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Account generation and role assignment</h3>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={selectedUserId}
|
||||||
|
onChange={(event) => setSelectedUserId(event.target.value)}
|
||||||
|
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="new">New user</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.firstName} {user.lastName} ({user.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
||||||
|
<input
|
||||||
|
value={userForm.email}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={userForm.password ?? ""}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
|
||||||
|
placeholder={selectedUserId === "new" ? "Required for new user" : "Leave blank to keep current password"}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">First name</span>
|
||||||
|
<input
|
||||||
|
value={userForm.firstName}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Last name</span>
|
||||||
|
<input
|
||||||
|
value={userForm.lastName}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={userForm.isActive}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, isActive: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
User can sign in
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<p className="text-sm font-semibold text-text">Assigned roles</p>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
{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">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={userForm.roleIds.includes(role.id)}
|
||||||
|
onChange={() => toggleUserRole(role.id)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold">{role.name}</span>
|
||||||
|
<span className="block text-xs text-muted">{role.description || "No description"}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<span className="text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||||
|
{selectedUserId === "new" ? "Create user" : "Save user"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleRoleSave}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roles</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Permission assignment administration</h3>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={selectedRoleId}
|
||||||
|
onChange={(event) => setSelectedRoleId(event.target.value)}
|
||||||
|
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="new">New role</option>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Role name</span>
|
||||||
|
<input
|
||||||
|
value={roleForm.name}
|
||||||
|
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
||||||
|
<textarea
|
||||||
|
value={roleForm.description}
|
||||||
|
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<p className="text-sm font-semibold text-text">Role permissions</p>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={roleForm.permissionKeys.includes(permission.key)}
|
||||||
|
onChange={() => toggleRolePermission(permission.key)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold">{permission.key}</span>
|
||||||
|
<span className="block text-xs text-muted">{permission.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-sm font-semibold text-text">{role.name}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
|
||||||
|
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<span className="text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||||
|
{selectedRoleId === "new" ? "Create role" : "Save role"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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 className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
||||||
|
<input
|
||||||
|
value={sessionQuery}
|
||||||
|
onChange={(event) => setSessionQuery(event.target.value)}
|
||||||
|
placeholder="User, email, IP, agent, review reason"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">User</span>
|
||||||
|
<select
|
||||||
|
value={sessionUserFilter}
|
||||||
|
onChange={(event) => setSessionUserFilter(event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All users</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select
|
||||||
|
value={sessionStatusFilter}
|
||||||
|
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="ALL">All statuses</option>
|
||||||
|
<option value="ACTIVE">Active</option>
|
||||||
|
<option value="EXPIRED">Expired</option>
|
||||||
|
<option value="REVOKED">Revoked</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Review</span>
|
||||||
|
<select
|
||||||
|
value={sessionReviewFilter}
|
||||||
|
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="ALL">All sessions</option>
|
||||||
|
<option value="REVIEW">Needs review</option>
|
||||||
|
<option value="NORMAL">Normal</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Active</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-text">{activeSessionCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Revoked</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-text">{revokedSessionCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Expired</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-text">{expiredSessionCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-amber-300/60 bg-amber-50 px-3 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Needs Review</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-amber-900">{reviewSessionCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{filteredSessions.map((session) => (
|
||||||
|
<div key={session.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-text">{session.userName}</p>
|
||||||
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
{session.status}
|
||||||
|
</span>
|
||||||
|
{session.reviewState === "REVIEW" ? (
|
||||||
|
<span className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">
|
||||||
|
Review
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{session.isCurrent ? (
|
||||||
|
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted">{session.userEmail}</p>
|
||||||
|
<div className="mt-3 grid gap-2 text-xs text-muted md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<p>Started: {new Date(session.createdAt).toLocaleString()}</p>
|
||||||
|
<p>Last seen: {new Date(session.lastSeenAt).toLocaleString()}</p>
|
||||||
|
<p>Expires: {new Date(session.expiresAt).toLocaleString()}</p>
|
||||||
|
<p>IP: {session.ipAddress || "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
|
||||||
|
{session.reviewReasons.length > 0 ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{session.reviewReasons.map((reason) => (
|
||||||
|
<span key={reason} className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-800">
|
||||||
|
{reason}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{session.revokedAt ? (
|
||||||
|
<p className="mt-2 text-xs text-muted">
|
||||||
|
Revoked {new Date(session.revokedAt).toLocaleString()}
|
||||||
|
{session.revokedByName ? ` by ${session.revokedByName}` : ""}.
|
||||||
|
{session.revokedReason ? ` ${session.revokedReason}` : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{session.status === "ACTIVE" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setPendingConfirmation({
|
||||||
|
kind: "revoke-session",
|
||||||
|
title: session.isCurrent ? "Revoke current session" : `Revoke session for ${session.userName}`,
|
||||||
|
description: session.isCurrent
|
||||||
|
? "Revoke the session you are using right now. Your current browser session will lose access immediately."
|
||||||
|
: `Revoke the selected active session for ${session.userEmail}.`,
|
||||||
|
impact: "The selected token becomes unusable immediately.",
|
||||||
|
recovery: "The user can sign in again unless the account itself is inactive. Review the remaining session list after revocation.",
|
||||||
|
confirmLabel: "Revoke session",
|
||||||
|
confirmationLabel: session.isCurrent ? "Type REVOKE to confirm:" : undefined,
|
||||||
|
confirmationValue: session.isCurrent ? "REVOKE" : undefined,
|
||||||
|
sessionId: session.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-2xl border border-red-300 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
|
||||||
|
>
|
||||||
|
Revoke session
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredSessions.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-line/70 bg-page/40 px-3 py-6 text-sm text-muted">
|
||||||
|
No sessions match the current filter.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm admin action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={isConfirmingAction}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isConfirmingAction) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConfirmingAction(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pendingConfirmation.kind === "deactivate-user" && pendingConfirmation.userId) {
|
||||||
|
await saveUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingConfirmation.kind === "revoke-session" && pendingConfirmation.sessionId) {
|
||||||
|
const isCurrentSession = sessions.find((session) => session.id === pendingConfirmation.sessionId)?.isCurrent ?? false;
|
||||||
|
await handleSessionRevoke(pendingConfirmation.sessionId, isCurrentSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
pendingConfirmation.kind === "deactivate-user" &&
|
||||||
|
pendingConfirmation.userId &&
|
||||||
|
pendingConfirmation.userId === authUser?.id
|
||||||
|
) {
|
||||||
|
setStatus("Your own account was deactivated. Sign-in will fail after this session ends unless another admin re-enables the account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
} finally {
|
||||||
|
setIsConfirmingAction(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
274
client/src/modules/shipping/ShipmentDetailPage.tsx
Normal file
274
client/src/modules/shipping/ShipmentDetailPage.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { ShipmentDetailDto, ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
|
import { shipmentStatusOptions } from "./config";
|
||||||
|
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
|
||||||
|
|
||||||
|
export function ShipmentDetailPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { shipmentId } = useParams();
|
||||||
|
const [shipment, setShipment] = useState<ShipmentDetailDto | null>(null);
|
||||||
|
const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||||
|
const [status, setStatus] = useState("Loading shipment...");
|
||||||
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
|
const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null);
|
||||||
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
|
| {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: string;
|
||||||
|
recovery: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmationLabel?: string;
|
||||||
|
confirmationValue?: string;
|
||||||
|
nextStatus: ShipmentStatus;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !shipmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getShipment(token, shipmentId)
|
||||||
|
.then((nextShipment) => {
|
||||||
|
setShipment(nextShipment);
|
||||||
|
setStatus("Shipment loaded.");
|
||||||
|
return api.getShipments(token, { salesOrderId: nextShipment.salesOrderId });
|
||||||
|
})
|
||||||
|
.then((shipments) => setRelatedShipments(shipments.filter((candidate) => candidate.id !== shipmentId)))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [shipmentId, token]);
|
||||||
|
|
||||||
|
async function applyStatusChange(nextStatus: ShipmentStatus) {
|
||||||
|
if (!token || !shipment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingStatus(true);
|
||||||
|
setStatus("Updating shipment status...");
|
||||||
|
try {
|
||||||
|
const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus);
|
||||||
|
setShipment(nextShipment);
|
||||||
|
setStatus("Shipment status updated. Verify carrier paperwork and sales-order expectations if the shipment moved into a terminal state.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to update shipment status.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(nextStatus: ShipmentStatus) {
|
||||||
|
if (!shipment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = shipmentStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
|
||||||
|
setPendingConfirmation({
|
||||||
|
title: `Set shipment to ${label}`,
|
||||||
|
description: `Update shipment ${shipment.shipmentNumber} from ${shipment.status} to ${nextStatus}.`,
|
||||||
|
impact:
|
||||||
|
nextStatus === "DELIVERED"
|
||||||
|
? "This marks delivery complete and can affect customer communication and project/shipping readiness views."
|
||||||
|
: nextStatus === "SHIPPED"
|
||||||
|
? "This marks the shipment as outbound and can trigger customer-facing tracking and downstream delivery expectations."
|
||||||
|
: "This changes the logistics state used by related shipping and sales workflows.",
|
||||||
|
recovery: "If the status is wrong, return the shipment to the correct state and confirm the linked sales order still reflects reality.",
|
||||||
|
confirmLabel: `Set ${label}`,
|
||||||
|
confirmationLabel: nextStatus === "DELIVERED" ? "Type shipment number to confirm:" : undefined,
|
||||||
|
confirmationValue: nextStatus === "DELIVERED" ? shipment.shipmentNumber : undefined,
|
||||||
|
nextStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenDocument(kind: "packing-slip" | "label" | "bol") {
|
||||||
|
if (!token || !shipment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveDocumentAction(kind);
|
||||||
|
setStatus(
|
||||||
|
kind === "packing-slip"
|
||||||
|
? "Rendering packing slip PDF..."
|
||||||
|
: kind === "label"
|
||||||
|
? "Rendering shipping label PDF..."
|
||||||
|
: "Rendering bill of lading PDF..."
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const blob =
|
||||||
|
kind === "packing-slip"
|
||||||
|
? await api.getShipmentPackingSlipPdf(token, shipment.id)
|
||||||
|
: kind === "label"
|
||||||
|
? await api.getShipmentLabelPdf(token, shipment.id)
|
||||||
|
: await api.getShipmentBillOfLadingPdf(token, shipment.id);
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
setStatus(
|
||||||
|
kind === "packing-slip"
|
||||||
|
? "Packing slip PDF rendered."
|
||||||
|
: kind === "label"
|
||||||
|
? "Shipping label PDF rendered."
|
||||||
|
: "Bill of lading PDF rendered."
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof ApiError
|
||||||
|
? error.message
|
||||||
|
: kind === "packing-slip"
|
||||||
|
? "Unable to render packing slip PDF."
|
||||||
|
: kind === "label"
|
||||||
|
? "Unable to render shipping label PDF."
|
||||||
|
: "Unable to render bill of lading PDF.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setActiveDocumentAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shipment) {
|
||||||
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{shipment.shipmentNumber}</h3>
|
||||||
|
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} · {shipment.customerName}</p>
|
||||||
|
<div className="mt-3"><ShipmentStatusBadge status={shipment.status} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link to="/shipping/shipments" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to shipments</Link>
|
||||||
|
<Link to={`/sales/orders/${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open sales order</Link>
|
||||||
|
<button type="button" onClick={() => handleOpenDocument("packing-slip")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{activeDocumentAction === "packing-slip" ? "Rendering PDF..." : "Open packing slip"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleOpenDocument("label")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{activeDocumentAction === "label" ? "Rendering PDF..." : "Open shipping label"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleOpenDocument("bol")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{activeDocumentAction === "bol" ? "Rendering PDF..." : "Open bill of lading"}
|
||||||
|
</button>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`/shipping/shipments/${shipment.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit shipment</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Update shipment status without opening the editor.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{shipmentStatusOptions.map((option) => (
|
||||||
|
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || shipment.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="grid gap-3 xl:grid-cols-4">
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Carrier</p><div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div></article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Service</p><div className="mt-2 text-base font-bold text-text">{shipment.serviceLevel || "Not set"}</div></article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tracking</p><div className="mt-2 text-base font-bold text-text">{shipment.trackingNumber || "Not set"}</div></article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Packages</p><div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div></article>
|
||||||
|
</section>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment Notes</p>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timing</p>
|
||||||
|
<dl className="mt-5 grid gap-3">
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt><dd className="mt-1 text-sm text-text">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.createdAt).toLocaleString()}</dd></div>
|
||||||
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Updated</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.updatedAt).toLocaleString()}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Related Shipments</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Other shipments already tied to this sales order.</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to={`/shipping/shipments/new?orderId=${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add another shipment</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{relatedShipments.length === 0 ? (
|
||||||
|
<div className="mt-6 rounded-[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-6 space-y-3">
|
||||||
|
{relatedShipments.map((related) => (
|
||||||
|
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-text">{related.shipmentNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{related.carrier || "Carrier not set"} · {related.trackingNumber || "No tracking"}</div>
|
||||||
|
</div>
|
||||||
|
<ShipmentStatusBadge status={related.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<FileAttachmentsPanel
|
||||||
|
ownerType="SHIPMENT"
|
||||||
|
ownerId={shipment.id}
|
||||||
|
eyebrow="Logistics Attachments"
|
||||||
|
title="Shipment files"
|
||||||
|
description="Store carrier paperwork, signed delivery records, bills of lading, and related logistics support files on the shipment record."
|
||||||
|
emptyMessage="No logistics attachments have been uploaded for this shipment yet."
|
||||||
|
/>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pendingConfirmation != null}
|
||||||
|
title={pendingConfirmation?.title ?? "Confirm shipment action"}
|
||||||
|
description={pendingConfirmation?.description ?? ""}
|
||||||
|
impact={pendingConfirmation?.impact}
|
||||||
|
recovery={pendingConfirmation?.recovery}
|
||||||
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
isConfirming={isUpdatingStatus}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isUpdatingStatus) {
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyStatusChange(pendingConfirmation.nextStatus);
|
||||||
|
setPendingConfirmation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
202
client/src/modules/shipping/ShipmentFormPage.tsx
Normal file
202
client/src/modules/shipping/ShipmentFormPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { emptyShipmentInput, shipmentStatusOptions } from "./config";
|
||||||
|
|
||||||
|
export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { shipmentId } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const seededOrderId = searchParams.get("orderId") ?? "";
|
||||||
|
const [form, setForm] = useState<ShipmentInput>({ ...emptyShipmentInput, salesOrderId: seededOrderId });
|
||||||
|
const [orderOptions, setOrderOptions] = useState<ShipmentOrderOptionDto[]>([]);
|
||||||
|
const [orderSearchTerm, setOrderSearchTerm] = useState("");
|
||||||
|
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
|
||||||
|
const [status, setStatus] = useState(mode === "create" ? "Create a new shipment." : "Loading shipment...");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getShipmentOrderOptions(token).then((options) => {
|
||||||
|
setOrderOptions(options);
|
||||||
|
const seeded = options.find((option) => option.id === seededOrderId);
|
||||||
|
if (seeded && mode === "create") {
|
||||||
|
setOrderSearchTerm(`${seeded.documentNumber} - ${seeded.customerName}`);
|
||||||
|
}
|
||||||
|
}).catch(() => setOrderOptions([]));
|
||||||
|
}, [mode, seededOrderId, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || mode !== "edit" || !shipmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getShipment(token, shipmentId)
|
||||||
|
.then((shipment) => {
|
||||||
|
setForm({
|
||||||
|
salesOrderId: shipment.salesOrderId,
|
||||||
|
status: shipment.status,
|
||||||
|
shipDate: shipment.shipDate,
|
||||||
|
carrier: shipment.carrier,
|
||||||
|
serviceLevel: shipment.serviceLevel,
|
||||||
|
trackingNumber: shipment.trackingNumber,
|
||||||
|
packageCount: shipment.packageCount,
|
||||||
|
notes: shipment.notes,
|
||||||
|
});
|
||||||
|
setOrderSearchTerm(`${shipment.salesOrderNumber} - ${shipment.customerName}`);
|
||||||
|
setStatus("Shipment loaded.");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [mode, shipmentId, token]);
|
||||||
|
|
||||||
|
function updateField<Key extends keyof ShipmentInput>(key: Key, value: ShipmentInput[Key]) {
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedOrder(orderId: string) {
|
||||||
|
return orderOptions.find((option) => option.id === orderId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Saving shipment...");
|
||||||
|
try {
|
||||||
|
const saved = mode === "create" ? await api.createShipment(token, form) : await api.updateShipment(token, shipmentId ?? "", form);
|
||||||
|
navigate(`/shipping/shipments/${saved.id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to save shipment.";
|
||||||
|
setStatus(message);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Editor</p>
|
||||||
|
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
|
||||||
|
</div>
|
||||||
|
<Link to={mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Sales Order</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={orderSearchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setOrderSearchTerm(event.target.value);
|
||||||
|
updateField("salesOrderId", "");
|
||||||
|
setOrderPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOrderPickerOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setOrderPickerOpen(false);
|
||||||
|
const selected = getSelectedOrder(form.salesOrderId);
|
||||||
|
if (selected) {
|
||||||
|
setOrderSearchTerm(`${selected.documentNumber} - ${selected.customerName}`);
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
placeholder="Search sales order"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
{orderPickerOpen ? (
|
||||||
|
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
|
||||||
|
{orderOptions
|
||||||
|
.filter((option) => {
|
||||||
|
const query = orderSearchTerm.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return option.documentNumber.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateField("salesOrderId", option.id);
|
||||||
|
setOrderSearchTerm(`${option.documentNumber} - ${option.customerName}`);
|
||||||
|
setOrderPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-text">{option.documentNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">{option.customerName}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
||||||
|
<select value={form.status} onChange={(event) => updateField("status", event.target.value as ShipmentInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
|
{shipmentStatusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Ship Date</span>
|
||||||
|
<input type="date" value={form.shipDate ? form.shipDate.slice(0, 10) : ""} onChange={(event) => updateField("shipDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Packages</span>
|
||||||
|
<input type="number" min={1} step={1} value={form.packageCount} onChange={(event) => updateField("packageCount", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Carrier</span>
|
||||||
|
<input value={form.carrier} onChange={(event) => updateField("carrier", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Service Level</span>
|
||||||
|
<input value={form.serviceLevel} onChange={(event) => updateField("serviceLevel", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Tracking Number</span>
|
||||||
|
<input value={form.trackingNumber} onChange={(event) => updateField("trackingNumber", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{isSaving ? "Saving..." : mode === "create" ? "Create shipment" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
117
client/src/modules/shipping/ShipmentListPage.tsx
Normal file
117
client/src/modules/shipping/ShipmentListPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import type { ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
|
import { api, ApiError } from "../../lib/api";
|
||||||
|
import { shipmentStatusFilters } from "./config";
|
||||||
|
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
|
||||||
|
|
||||||
|
export function ShipmentListPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | ShipmentStatus>("ALL");
|
||||||
|
const [status, setStatus] = useState("Loading shipments...");
|
||||||
|
|
||||||
|
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getShipments(token, {
|
||||||
|
q: searchTerm.trim() || undefined,
|
||||||
|
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||||
|
})
|
||||||
|
.then((nextShipments) => {
|
||||||
|
setShipments(nextShipments);
|
||||||
|
setStatus(`${nextShipments.length} shipments matched the current filters.`);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to load shipments.";
|
||||||
|
setStatus(message);
|
||||||
|
});
|
||||||
|
}, [searchTerm, statusFilter, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
|
||||||
|
<h3 className="mt-2 text-lg font-bold text-text">Shipments</h3>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted">Outbound shipment records tied to sales orders, carriers, and tracking details.</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Link to="/shipping/shipments/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||||
|
New shipment
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder="Search by shipment, order, customer, carrier, or tracking"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value as "ALL" | ShipmentStatus)}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{shipmentStatusFilters.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
|
||||||
|
{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-6 overflow-hidden rounded-2xl border border-line/70">
|
||||||
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
|
<thead className="bg-page/80 text-left text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2">Shipment</th>
|
||||||
|
<th className="px-2 py-2">Sales Order</th>
|
||||||
|
<th className="px-2 py-2">Customer</th>
|
||||||
|
<th className="px-2 py-2">Status</th>
|
||||||
|
<th className="px-2 py-2">Carrier</th>
|
||||||
|
<th className="px-2 py-2">Ship Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/70 bg-surface">
|
||||||
|
{shipments.map((shipment) => (
|
||||||
|
<tr key={shipment.id} className="transition hover:bg-page/70">
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<Link to={`/shipping/shipments/${shipment.id}`} className="font-semibold text-text hover:text-brand">
|
||||||
|
{shipment.shipmentNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{shipment.salesOrderNumber}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{shipment.customerName}</td>
|
||||||
|
<td className="px-2 py-2"><ShipmentStatusBadge status={shipment.status} /></td>
|
||||||
|
<td className="px-2 py-2 text-muted">{shipment.carrier || "Not set"}</td>
|
||||||
|
<td className="px-2 py-2 text-muted">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
8
client/src/modules/shipping/ShipmentStatusBadge.tsx
Normal file
8
client/src/modules/shipping/ShipmentStatusBadge.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
|
||||||
|
|
||||||
|
import { shipmentStatusOptions, shipmentStatusPalette } from "./config";
|
||||||
|
|
||||||
|
export function ShipmentStatusBadge({ status }: { status: ShipmentStatus }) {
|
||||||
|
const label = shipmentStatusOptions.find((option) => option.value === status)?.label ?? status;
|
||||||
|
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${shipmentStatusPalette[status]}`}>{label}</span>;
|
||||||
|
}
|
||||||
5
client/src/modules/shipping/ShipmentsPage.tsx
Normal file
5
client/src/modules/shipping/ShipmentsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export function ShipmentsPage() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
33
client/src/modules/shipping/config.ts
Normal file
33
client/src/modules/shipping/config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ShipmentInput, ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
|
||||||
|
|
||||||
|
export const shipmentStatusOptions: Array<{ value: ShipmentStatus; label: string }> = [
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
{ value: "PICKING", label: "Picking" },
|
||||||
|
{ value: "PACKED", label: "Packed" },
|
||||||
|
{ value: "SHIPPED", label: "Shipped" },
|
||||||
|
{ value: "DELIVERED", label: "Delivered" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const shipmentStatusFilters: Array<{ value: "ALL" | ShipmentStatus; label: string }> = [
|
||||||
|
{ value: "ALL", label: "All statuses" },
|
||||||
|
...shipmentStatusOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const shipmentStatusPalette: Record<ShipmentStatus, string> = {
|
||||||
|
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
|
||||||
|
PICKING: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
|
||||||
|
PACKED: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||||
|
SHIPPED: "border border-brand/30 bg-brand/10 text-brand",
|
||||||
|
DELIVERED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyShipmentInput: ShipmentInput = {
|
||||||
|
salesOrderId: "",
|
||||||
|
status: "DRAFT",
|
||||||
|
shipDate: null,
|
||||||
|
carrier: "",
|
||||||
|
serviceLevel: "",
|
||||||
|
trackingNumber: "",
|
||||||
|
packageCount: 1,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
2
client/src/tests/setup.ts
Normal file
2
client/src/tests/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
52
client/src/tests/theme.test.tsx
Normal file
52
client/src/tests/theme.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ThemeProvider } from "../theme/ThemeProvider";
|
||||||
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
|
|
||||||
|
describe("ThemeToggle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
document.documentElement.removeAttribute("style");
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the html dark class", () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeToggle />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates persisted brand theme values on startup", async () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"mrp.theme.brand-profile",
|
||||||
|
JSON.stringify({
|
||||||
|
theme: {
|
||||||
|
primaryColor: "#112233",
|
||||||
|
accentColor: "#445566",
|
||||||
|
surfaceColor: "#778899",
|
||||||
|
fontFamily: "Manrope",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<div>Theme</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--color-brand")).toBe("17 34 51");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("68 85 102");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--color-surface-brand")).toBe("119 136 153");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("Manrope");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
78
client/src/theme/ThemeProvider.tsx
Normal file
78
client/src/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { CompanyProfileDto } from "@mrp/shared";
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { hexToRgbTriplet } from "./utils";
|
||||||
|
|
||||||
|
type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
mode: ThemeMode;
|
||||||
|
toggleMode: () => void;
|
||||||
|
applyBrandProfile: (profile: CompanyProfileDto | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
const storageKey = "mrp.theme.mode";
|
||||||
|
const brandProfileKey = "mrp.theme.brand-profile";
|
||||||
|
|
||||||
|
function applyThemeVariables(profile: Pick<CompanyProfileDto, "theme">) {
|
||||||
|
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
|
||||||
|
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
|
||||||
|
document.documentElement.style.setProperty("--color-surface-brand", hexToRgbTriplet(profile.theme.surfaceColor));
|
||||||
|
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||||
|
const stored = window.localStorage.getItem(storageKey);
|
||||||
|
return stored === "dark" ? "dark" : "light";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedBrandProfile = window.localStorage.getItem(brandProfileKey);
|
||||||
|
if (!storedBrandProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storedBrandProfile) as Pick<CompanyProfileDto, "theme">;
|
||||||
|
applyThemeVariables(parsed);
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem(brandProfileKey);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||||
|
document.documentElement.style.colorScheme = mode;
|
||||||
|
window.localStorage.setItem(storageKey, mode);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
const applyBrandProfile = (profile: CompanyProfileDto | null) => {
|
||||||
|
if (!profile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyThemeVariables(profile);
|
||||||
|
window.localStorage.setItem(brandProfileKey, JSON.stringify({ theme: profile.theme }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
mode,
|
||||||
|
toggleMode: () => setMode((current) => (current === "light" ? "dark" : "light")),
|
||||||
|
applyBrandProfile,
|
||||||
|
}),
|
||||||
|
[mode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
9
client/src/theme/utils.ts
Normal file
9
client/src/theme/utils.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function hexToRgbTriplet(hex: string) {
|
||||||
|
const normalized = hex.replace("#", "");
|
||||||
|
const numeric = Number.parseInt(normalized, 16);
|
||||||
|
const r = (numeric >> 16) & 255;
|
||||||
|
const g = (numeric >> 8) & 255;
|
||||||
|
const b = numeric & 255;
|
||||||
|
return `${r} ${g} ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
27
client/tailwind.config.ts
Normal file
27
client/tailwind.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "rgb(var(--color-brand) / <alpha-value>)",
|
||||||
|
accent: "rgb(var(--color-accent) / <alpha-value>)",
|
||||||
|
surface: "rgb(var(--color-surface) / <alpha-value>)",
|
||||||
|
page: "rgb(var(--color-page) / <alpha-value>)",
|
||||||
|
text: "rgb(var(--color-text) / <alpha-value>)",
|
||||||
|
muted: "rgb(var(--color-muted) / <alpha-value>)",
|
||||||
|
line: "rgb(var(--color-line) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-family)", "ui-sans-serif", "system-ui"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
panel: "0 24px 60px rgba(15, 23, 42, 0.14)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
|
|
||||||
23
client/tsconfig.json
Normal file
23
client/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
client/tsconfig.node.json
Normal file
12
client/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"tailwind.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
36
client/vite.config.ts
Normal file
36
client/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes("@svar-ui/react-gantt")) {
|
||||||
|
return "gantt-vendor";
|
||||||
|
}
|
||||||
|
if (id.includes("@tanstack/react-query")) {
|
||||||
|
return "query-vendor";
|
||||||
|
}
|
||||||
|
if (id.includes("react-router-dom") || id.includes("react-dom") || id.includes(`${path.sep}react${path.sep}`)) {
|
||||||
|
return "react-vendor";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
11
client/vitest.config.ts
Normal file
11
client/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./src/tests/setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
10
docker-entrypoint.sh
Normal file
10
docker-entrypoint.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
mkdir -p /app/data/prisma /app/data/uploads
|
||||||
|
|
||||||
|
echo "Applying Prisma migrations..."
|
||||||
|
/app/server/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma
|
||||||
|
|
||||||
|
echo "Starting MRP Codex..."
|
||||||
|
exec node /app/server/dist/server.js
|
||||||
7448
package-lock.json
generated
Normal file
7448
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "mrp-codex",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server",
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm:dev -w shared\" \"npm:dev -w server\" \"npm:dev -w client\"",
|
||||||
|
"build": "npm run build -w shared && npm run build -w server && npm run build -w client",
|
||||||
|
"test": "npm run test -w shared && npm run test -w server && npm run test -w client",
|
||||||
|
"lint": "npm run lint -w shared && npm run lint -w server && npm run lint -w client",
|
||||||
|
"prisma:generate": "npm run prisma:generate -w server",
|
||||||
|
"prisma:migrate": "npm run prisma:migrate -w server",
|
||||||
|
"prisma:deploy": "npm run prisma:deploy -w server"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.0.0",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
server/package.json
Normal file
41
server/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@mrp/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "dotenv -e ../.env -- prisma migrate dev --name foundation",
|
||||||
|
"prisma:deploy": "dotenv -e ../.env -- prisma migrate deploy"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mrp/shared": "0.1.0",
|
||||||
|
"@prisma/client": "^6.16.2",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^4.22.1",
|
||||||
|
"express-async-errors": "^3.1.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"pino-http": "^11.0.0",
|
||||||
|
"prisma": "^6.16.2",
|
||||||
|
"puppeteer": "^24.39.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^24.5.2",
|
||||||
|
"dotenv-cli": "^8.0.0",
|
||||||
|
"tsx": "^4.20.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
139
server/prisma/migrations/20260314193000_foundation/migration.sql
Normal file
139
server/prisma/migrations/20260314193000_foundation/migration.sql
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Role" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Permission" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserRole" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"assignedBy" TEXT,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "roleId"),
|
||||||
|
CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RolePermission" (
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"permissionId" TEXT NOT NULL,
|
||||||
|
"grantedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY ("roleId", "permissionId"),
|
||||||
|
CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CompanyProfile" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"legalName" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"website" TEXT NOT NULL,
|
||||||
|
"taxId" TEXT NOT NULL,
|
||||||
|
"addressLine1" TEXT NOT NULL,
|
||||||
|
"addressLine2" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"postalCode" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"primaryColor" TEXT NOT NULL DEFAULT '#185ADB',
|
||||||
|
"accentColor" TEXT NOT NULL DEFAULT '#00A6A6',
|
||||||
|
"surfaceColor" TEXT NOT NULL DEFAULT '#F4F7FB',
|
||||||
|
"fontFamily" TEXT NOT NULL DEFAULT 'Manrope',
|
||||||
|
"logoFileId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "CompanyProfile_logoFileId_fkey" FOREIGN KEY ("logoFileId") REFERENCES "FileAttachment" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FileAttachment" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"originalName" TEXT NOT NULL,
|
||||||
|
"storedName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"sizeBytes" INTEGER NOT NULL,
|
||||||
|
"relativePath" TEXT NOT NULL,
|
||||||
|
"ownerType" TEXT NOT NULL,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Customer" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"addressLine1" TEXT NOT NULL,
|
||||||
|
"addressLine2" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"postalCode" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"notes" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Vendor" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"addressLine1" TEXT NOT NULL,
|
||||||
|
"addressLine2" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"postalCode" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"notes" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CompanyProfile_logoFileId_key" ON "CompanyProfile"("logoFileId");
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user