projects
This commit is contained in:
15
AGENTS.md
15
AGENTS.md
@@ -17,7 +17,8 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
|
||||
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
|
||||
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
|
||||
- sales quotes, sales orders, and purchase orders
|
||||
- shipping shipments and packing-slip PDFs
|
||||
- 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
|
||||
- Puppeteer PDF foundation
|
||||
- single-container Docker deployment
|
||||
|
||||
@@ -105,7 +106,7 @@ If implementation changes invalidate those docs, update them in the same change
|
||||
- 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 should be treated as a first-class future domain that anchors long-running program execution across CRM, sales, inventory, purchasing, shipping, and planning
|
||||
- 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 should remain a separate future domain for work orders, routings, labor, and shop-floor execution
|
||||
- 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
|
||||
@@ -114,11 +115,11 @@ If implementation changes invalidate those docs, update them in the same change
|
||||
|
||||
Near-term priorities are:
|
||||
|
||||
1. Shipping labels, bills of lading, and logistics attachments
|
||||
2. Projects and program management
|
||||
3. Manufacturing execution
|
||||
4. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
|
||||
5. Sales approvals and document revision history
|
||||
1. Manufacturing execution
|
||||
2. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
|
||||
3. Sales approvals and document revision history
|
||||
4. Planning and gantt scheduling with live project/manufacturing data
|
||||
5. Inventory transfers, reservations, and deeper stock controls
|
||||
|
||||
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
||||
|
||||
### Added
|
||||
|
||||
- No unreleased entries recorded yet.
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
|
||||
- No unreleased entries recorded yet.
|
||||
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
|
||||
- Roadmap and project docs now treat manufacturing execution as the next active priority after the projects foundation slice
|
||||
|
||||
## 2026-03-15
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ This repository implements the platform foundation milestone:
|
||||
- purchase orders restricted to inventory items flagged as purchasable
|
||||
- purchase receiving foundation with inventory posting and receipt history
|
||||
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline
|
||||
- shipping shipments linked to sales orders and packing-slip PDFs
|
||||
- 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
|
||||
- Dockerized single-container deployment
|
||||
- Puppeteer PDF pipeline foundation
|
||||
|
||||
@@ -36,7 +37,7 @@ This repository implements the platform foundation milestone:
|
||||
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 future `Projects` work as a first-class cross-module domain tying together CRM, sales, inventory, purchasing, shipping, and planning; do not bury it as a one-off manufacturing subfeature.
|
||||
11. Treat `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.
|
||||
|
||||
@@ -56,10 +57,9 @@ This repository implements the platform foundation milestone:
|
||||
|
||||
## Next roadmap candidates
|
||||
|
||||
- shipping labels, bills of lading, and logistics attachments
|
||||
- projects and program management
|
||||
- manufacturing execution
|
||||
- vendor invoice/supporting-document attachments and broader vendor-side operational depth
|
||||
- sales approvals and document revision history
|
||||
- planning and gantt scheduling with live project/manufacturing data
|
||||
- inventory transfers, reservations, and deeper stock controls
|
||||
- broader audit and operations maturity
|
||||
|
||||
43
README.md
43
README.md
@@ -19,7 +19,8 @@ Current foundation scope includes:
|
||||
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
|
||||
- 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
|
||||
- shipping shipments linked to sales orders with packing-slip PDFs
|
||||
- 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
|
||||
- file storage and PDF rendering
|
||||
|
||||
## Product Map
|
||||
@@ -31,21 +32,21 @@ Current completed foundation areas:
|
||||
- inventory foundation
|
||||
- sales and purchasing foundation
|
||||
- shipping foundation
|
||||
- projects foundation
|
||||
- branding, attachments, auth/RBAC, and PDF infrastructure
|
||||
|
||||
Planned cross-module execution areas:
|
||||
|
||||
- projects and program management
|
||||
- manufacturing execution
|
||||
- planning and gantt scheduling
|
||||
|
||||
Near-term priorities:
|
||||
|
||||
1. Shipping labels, bills of lading, and logistics attachments
|
||||
2. Projects and program management
|
||||
3. Manufacturing execution
|
||||
4. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth
|
||||
5. Sales approvals and revision history
|
||||
1. Manufacturing execution
|
||||
2. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth
|
||||
3. Sales approvals and revision history
|
||||
4. Planning and gantt scheduling with live project/manufacturing data
|
||||
5. Inventory transfers, reservations, and deeper stock controls
|
||||
|
||||
Revisit / deferred items:
|
||||
|
||||
@@ -54,7 +55,6 @@ Revisit / deferred items:
|
||||
- sales approvals and revision history
|
||||
- inventory transfers, reservations, and deeper stock controls
|
||||
- deeper audit-trail coverage
|
||||
- projects are not yet first-class records even though planning/manufacturing flows will need them
|
||||
|
||||
Dashboard direction:
|
||||
|
||||
@@ -63,7 +63,8 @@ Dashboard direction:
|
||||
- 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 should eventually feed dashboard widgets for active jobs, overdue milestones, shortages, and shipment readiness
|
||||
- projects now feed dashboard widgets for active programs, overdue work, and risk
|
||||
- future project widgets should deepen milestones, shortages, and shipment readiness
|
||||
|
||||
Navigation direction:
|
||||
|
||||
@@ -73,18 +74,21 @@ Navigation direction:
|
||||
|
||||
## Projects Direction
|
||||
|
||||
Projects should become the long-running program and delivery layer tying together commercial work, engineering context, purchasing, manufacturing, shipping, and customer-facing execution.
|
||||
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.
|
||||
|
||||
Planned interactions:
|
||||
Current interactions:
|
||||
|
||||
- CRM: each project should link to a customer account and relevant contacts
|
||||
- Sales: quotes and sales orders should be able to spawn or attach to projects
|
||||
- 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
|
||||
- Shipping: shipments tied to project deliverables should be visible from the project record
|
||||
- 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
|
||||
- Dashboard: projects should contribute status, risk, backlog, and milestone widgets
|
||||
|
||||
## Manufacturing Direction
|
||||
|
||||
@@ -252,12 +256,12 @@ The current shipping foundation supports:
|
||||
- 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:
|
||||
|
||||
- shipment labels
|
||||
- bills of lading
|
||||
- logistics attachments and reprint/history actions
|
||||
- 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.
|
||||
@@ -303,17 +307,18 @@ As of March 14, 2026, the latest committed domain migrations include:
|
||||
- inventory default price support
|
||||
- sales totals and commercial fields
|
||||
- shipping foundation
|
||||
- projects foundation
|
||||
|
||||
Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment.
|
||||
|
||||
## UI Notes
|
||||
|
||||
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
|
||||
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, settings, and planning modules from the same app shell.
|
||||
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, projects, 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, manufacturing, and audit data.
|
||||
- The client build still emits a Vite chunk-size warning because the app has not been code-split yet.
|
||||
|
||||
## 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, and shipment packing slips. The Docker image includes Chromium runtime dependencies required for headless execution.
|
||||
Puppeteer is used by the backend to render HTML templates into professional PDFs. The 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.
|
||||
|
||||
30
ROADMAP.md
30
ROADMAP.md
@@ -38,7 +38,10 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
- 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 PDF rendering for shipments
|
||||
- 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
|
||||
- SKU-searchable BOM component selection for inventory-scale datasets
|
||||
- Theme persistence fixes and denser responsive workspace layouts
|
||||
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||
@@ -52,8 +55,9 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
|
||||
- The frontend bundle is functional but should be code-split later, especially around the gantt module
|
||||
- CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
|
||||
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, shipment labels, or vendor-side attachment handling
|
||||
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, vendor-side attachment handling, or deeper carrier integration
|
||||
- The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added
|
||||
- The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins
|
||||
|
||||
## Dashboard Plan
|
||||
|
||||
@@ -61,6 +65,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
- Expand it by modular panels rather than redesigning it for each new feature phase
|
||||
- Prefer metric cards, exception queues, action shortcuts, and status summaries over static descriptive content
|
||||
- Add future widgets for purchasing, shipping exceptions, inventory shortages, manufacturing load, and audit/system health
|
||||
- Continue expanding the new project widgets into milestone, blockage, and shipment-readiness views instead of creating a separate landing area
|
||||
- Treat dashboard modules as upgradeable blocks that can be reordered or expanded without disturbing the shell
|
||||
|
||||
## Planned feature phases
|
||||
@@ -128,7 +133,7 @@ QOL subfeatures:
|
||||
|
||||
QOL subfeatures:
|
||||
|
||||
- Shipment labels and printer-friendly document actions
|
||||
- Printer-friendly reprint and history actions for logistics documents
|
||||
- Partial shipment workflow and split-shipment visibility
|
||||
- Better tracking-link UX and carrier-specific shortcuts
|
||||
- Packing verification and ship-confirm checkpoints
|
||||
@@ -137,8 +142,13 @@ QOL subfeatures:
|
||||
|
||||
### Phase 5: Projects and program management
|
||||
|
||||
Foundation slice shipped:
|
||||
|
||||
- Project records with customer linkage, status, owner, priority, due dates, and notes
|
||||
- Project-to-sales-order and quote linkage so commercial commitments can roll into delivery programs
|
||||
- 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
|
||||
|
||||
- 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
|
||||
@@ -231,13 +241,11 @@ QOL subfeatures:
|
||||
- Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
|
||||
- Sales approvals and document revision history were planned but not yet built
|
||||
- Vendor invoice/supporting-document attachments still need to be added
|
||||
- Shipping is now linked to sales orders, but labels, bills of lading, and logistics attachments are still pending
|
||||
- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up
|
||||
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist
|
||||
- Audit-trail depth is still thin outside the current record/update flows
|
||||
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
|
||||
- Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred
|
||||
- Projects are not yet implemented as first-class long-running program records
|
||||
- Manufacturing execution is not yet separated cleanly from planning/scheduling in the current future-state docs and implementation
|
||||
|
||||
## Cross-cutting improvements
|
||||
@@ -251,8 +259,8 @@ QOL subfeatures:
|
||||
|
||||
## Near-term priority order
|
||||
|
||||
1. Shipping labels, bills of lading, and logistics attachments
|
||||
2. Projects and program management
|
||||
3. Manufacturing execution
|
||||
4. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
|
||||
5. Sales approvals and document revision history
|
||||
1. Manufacturing execution
|
||||
2. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
|
||||
3. Sales approvals and document revision history
|
||||
4. Planning and scheduling with live project/manufacturing data
|
||||
5. Inventory transfers, reservations, and deeper stock controls
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 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 when implemented; it should integrate with CRM, sales, inventory, purchasing, shipping, and planning rather than living inside only one of those modules.
|
||||
- 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.
|
||||
|
||||
@@ -130,7 +130,7 @@ When you publish a new image:
|
||||
|
||||
Because MRP Codex 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, packing slips, the inventory `defaultPrice` field, and purchasable-only purchase-order item selection. Let the container complete startup migrations before testing new screens.
|
||||
This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, and the new projects domain. Let the container complete startup migrations before testing new screens.
|
||||
|
||||
## Backup guidance
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const links = [
|
||||
{ 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: "/planning/gantt", label: "Gantt", icon: <GanttIcon /> },
|
||||
];
|
||||
|
||||
@@ -156,6 +157,19 @@ function GanttIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
|
||||
@@ -33,6 +33,17 @@ import type {
|
||||
WarehouseLocationOptionDto,
|
||||
WarehouseSummaryDto,
|
||||
} from "@mrp/shared/dist/inventory/types.js";
|
||||
import type {
|
||||
ProjectCustomerOptionDto,
|
||||
ProjectDetailDto,
|
||||
ProjectDocumentOptionDto,
|
||||
ProjectInput,
|
||||
ProjectOwnerOptionDto,
|
||||
ProjectPriority,
|
||||
ProjectShipmentOptionDto,
|
||||
ProjectStatus,
|
||||
ProjectSummaryDto,
|
||||
} from "@mrp/shared/dist/projects/types.js";
|
||||
import type {
|
||||
SalesCustomerOptionDto,
|
||||
SalesDocumentDetailDto,
|
||||
@@ -381,6 +392,58 @@ export const api = {
|
||||
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
|
||||
);
|
||||
},
|
||||
getGanttDemo(token: string) {
|
||||
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
|
||||
},
|
||||
@@ -521,6 +584,32 @@ export const api = {
|
||||
|
||||
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: {
|
||||
|
||||
@@ -21,6 +21,9 @@ import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage";
|
||||
import { PurchaseDetailPage } from "./modules/purchasing/PurchaseDetailPage";
|
||||
import { PurchaseFormPage } from "./modules/purchasing/PurchaseFormPage";
|
||||
import { PurchaseListPage } from "./modules/purchasing/PurchaseListPage";
|
||||
import { ProjectDetailPage } from "./modules/projects/ProjectDetailPage";
|
||||
import { ProjectFormPage } from "./modules/projects/ProjectFormPage";
|
||||
import { ProjectsPage } from "./modules/projects/ProjectsPage";
|
||||
import { WarehouseDetailPage } from "./modules/inventory/WarehouseDetailPage";
|
||||
import { WarehouseFormPage } from "./modules/inventory/WarehouseFormPage";
|
||||
import { WarehousesPage } from "./modules/inventory/WarehousesPage";
|
||||
@@ -66,6 +69,13 @@ const router = createBrowserRouter([
|
||||
{ path: "/inventory/warehouses/:warehouseId", element: <WarehouseDetailPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />,
|
||||
children: [
|
||||
{ path: "/projects", element: <ProjectsPage /> },
|
||||
{ path: "/projects/:projectId", element: <ProjectDetailPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
|
||||
children: [
|
||||
@@ -98,6 +108,13 @@ const router = createBrowserRouter([
|
||||
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />,
|
||||
children: [
|
||||
{ path: "/projects/new", element: <ProjectFormPage mode="create" /> },
|
||||
{ path: "/projects/:projectId/edit", element: <ProjectFormPage mode="edit" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
|
||||
children: [
|
||||
|
||||
@@ -13,6 +13,7 @@ interface DashboardSnapshot {
|
||||
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;
|
||||
refreshedAt: string;
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ export function DashboardPage() {
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user) {
|
||||
@@ -67,6 +69,7 @@ export function DashboardPage() {
|
||||
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
|
||||
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([
|
||||
@@ -77,6 +80,7 @@ export function DashboardPage() {
|
||||
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),
|
||||
]);
|
||||
|
||||
if (!isMounted) {
|
||||
@@ -97,6 +101,7 @@ export function DashboardPage() {
|
||||
quotes: results[4].status === "fulfilled" ? results[4].value : null,
|
||||
orders: results[5].status === "fulfilled" ? results[5].value : null,
|
||||
shipments: results[6].status === "fulfilled" ? results[6].value : null,
|
||||
projects: results[7].status === "fulfilled" ? results[7].value : null,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
});
|
||||
setIsLoading(false);
|
||||
@@ -124,12 +129,14 @@ export function DashboardPage() {
|
||||
const quotes = snapshot?.quotes ?? [];
|
||||
const orders = snapshot?.orders ?? [];
|
||||
const shipments = snapshot?.shipments ?? [];
|
||||
const projects = snapshot?.projects ?? [];
|
||||
|
||||
const accessibleModules = [
|
||||
snapshot?.customers !== null || snapshot?.vendors !== null,
|
||||
snapshot?.items !== null || snapshot?.warehouses !== null,
|
||||
snapshot?.quotes !== null || snapshot?.orders !== null,
|
||||
snapshot?.shipments !== null,
|
||||
snapshot?.projects !== null,
|
||||
].filter(Boolean).length;
|
||||
|
||||
const customerCount = customers.length;
|
||||
@@ -157,6 +164,17 @@ export function DashboardPage() {
|
||||
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 lastActivityAt = [
|
||||
...customers.map((customer) => customer.updatedAt),
|
||||
...vendors.map((vendor) => vendor.updatedAt),
|
||||
@@ -165,6 +183,7 @@ export function DashboardPage() {
|
||||
...quotes.map((quote) => quote.updatedAt),
|
||||
...orders.map((order) => order.updatedAt),
|
||||
...shipments.map((shipment) => shipment.updatedAt),
|
||||
...projects.map((project) => project.updatedAt),
|
||||
]
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
@@ -206,6 +225,15 @@ export function DashboardPage() {
|
||||
: "Shipping metrics are permission-gated.",
|
||||
tone: "border-brand/30 bg-brand/10 text-brand",
|
||||
},
|
||||
{
|
||||
label: "Project Load",
|
||||
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
|
||||
detail:
|
||||
snapshot?.projects !== null
|
||||
? `${atRiskProjectCount} at risk and ${overdueProjectCount} overdue`
|
||||
: "Project metrics are permission-gated.",
|
||||
tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
||||
},
|
||||
];
|
||||
|
||||
const modulePanels = [
|
||||
@@ -277,13 +305,31 @@ export function DashboardPage() {
|
||||
{ label: "Open packing flow", to: "/sales/orders" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
eyebrow: "Program Control",
|
||||
summary:
|
||||
snapshot?.projects !== null
|
||||
? "Project records now tie customers, commercial documents, shipment context, and delivery ownership into one operational surface."
|
||||
: "Project read permission is required to surface program metrics here.",
|
||||
metrics: [
|
||||
{ label: "Active", value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access" },
|
||||
{ label: "At risk", value: snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access" },
|
||||
{ label: "Overdue", value: snapshot?.projects !== null ? `${overdueProjectCount}` : "No access" },
|
||||
],
|
||||
links: [
|
||||
{ label: "Open projects", to: "/projects" },
|
||||
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const futureModules = [
|
||||
"Purchase-order queue and supplier receipts",
|
||||
"Vendor invoice attachments and supplier exception queues",
|
||||
"Stock transfers, allocations, and cycle counts",
|
||||
"Shipping labels, bills of lading, and delivery exceptions",
|
||||
"Manufacturing schedule, work orders, and bottleneck metrics",
|
||||
"Manufacturing work orders, routings, and bottleneck metrics",
|
||||
"Planning timeline, milestones, and dependency views",
|
||||
"Audit trails, diagnostics, and system health checks",
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -304,8 +350,8 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
|
||||
This landing page now reads directly from live CRM, inventory, sales, and shipping data. It is intentionally modular so future purchasing,
|
||||
manufacturing, and audit slices can slot into the same command surface without a redesign.
|
||||
This landing page now reads directly from live CRM, inventory, sales, shipping, and project data. It is intentionally modular so future
|
||||
purchasing, manufacturing, and audit slices can slot into the same command surface without a redesign.
|
||||
</p>
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
|
||||
@@ -331,6 +377,9 @@ export function DashboardPage() {
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items">
|
||||
Open inventory
|
||||
</Link>
|
||||
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
|
||||
Open projects
|
||||
</Link>
|
||||
</div>
|
||||
{error ? <div className="mt-4 rounded-2xl border border-amber-400/30 bg-amber-500/12 px-2 py-2 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
|
||||
</div>
|
||||
@@ -347,7 +396,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<section className="grid gap-3 xl:grid-cols-5">
|
||||
{metricCards.map((card) => (
|
||||
<article key={card.label} className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{card.label}</p>
|
||||
@@ -359,7 +408,7 @@ export function DashboardPage() {
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
|
||||
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-5">
|
||||
{modulePanels.map((panel) => (
|
||||
<article key={panel.title} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{panel.eyebrow}</p>
|
||||
@@ -383,7 +432,7 @@ export function DashboardPage() {
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-3">
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>
|
||||
@@ -420,6 +469,24 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Program status and delivery pressure</h4>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Total projects</span>
|
||||
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${projectCount}` : "No access"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">At risk</span>
|
||||
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<span className="text-muted">Overdue</span>
|
||||
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${overdueProjectCount}` : "No access"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Watch</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Execution and delivery status</h4>
|
||||
|
||||
107
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
107
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { 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 [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.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load project.";
|
||||
setStatus(message);
|
||||
});
|
||||
}, [projectId, token]);
|
||||
|
||||
if (!project) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Customer</p><div className="mt-2 text-base font-bold text-text">{project.customerName}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Owner</p><div className="mt-2 text-base font-bold text-text">{project.ownerName || "Unassigned"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
||||
</section>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p>
|
||||
<dl className="mt-5 grid gap-3">
|
||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</Link></dd></div>
|
||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{project.customerEmail}</dd></div>
|
||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Program Notes</p>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
|
||||
</article>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Commercial + Delivery Links</p>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</div>
|
||||
<div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sales Order</div>
|
||||
<div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment</div>
|
||||
<div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
198
client/src/modules/projects/ProjectFormPage.tsx
Normal file
198
client/src/modules/projects/ProjectFormPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
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 { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
|
||||
|
||||
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 [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
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-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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-[28px] 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>
|
||||
<select value={form.customerId} onChange={(event) => updateField("customerId", 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 customer</option>
|
||||
{customerOptions.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}
|
||||
</select>
|
||||
</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>
|
||||
<select value={form.ownerId ?? ""} onChange={(event) => updateField("ownerId", 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="">Unassigned</option>
|
||||
{ownerOptions.map((owner) => <option key={owner.id} value={owner.id}>{owner.fullName}</option>)}
|
||||
</select>
|
||||
</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>
|
||||
<select value={form.salesQuoteId ?? ""} onChange={(event) => updateField("salesQuoteId", 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 linked quote</option>
|
||||
{quoteOptions.map((quote) => <option key={quote.id} value={quote.id}>{quote.documentNumber}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
|
||||
<select value={form.salesOrderId ?? ""} onChange={(event) => updateField("salesOrderId", 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 linked sales order</option>
|
||||
{orderOptions.map((order) => <option key={order.id} value={order.id}>{order.documentNumber}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
|
||||
<select value={form.shipmentId ?? ""} onChange={(event) => updateField("shipmentId", 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 linked shipment</option>
|
||||
{shipmentOptions.map((shipment) => <option key={shipment.id} value={shipment.id}>{shipment.shipmentNumber}</option>)}
|
||||
</select>
|
||||
</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-3xl 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>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
116
client/src/modules/projects/ProjectListPage.tsx
Normal file
116
client/src/modules/projects/ProjectListPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
|
||||
<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-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No projects are available for the current filters.</div>
|
||||
) : (
|
||||
<div className="mt-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: "",
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { shipmentStatusOptions } from "./config";
|
||||
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
|
||||
|
||||
@@ -15,7 +16,7 @@ export function ShipmentDetailPage() {
|
||||
const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||
const [status, setStatus] = useState("Loading shipment...");
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isRenderingPdf, setIsRenderingPdf] = useState(false);
|
||||
const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
|
||||
|
||||
@@ -56,24 +57,48 @@ export function ShipmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenPackingSlip() {
|
||||
async function handleOpenDocument(kind: "packing-slip" | "label" | "bol") {
|
||||
if (!token || !shipment) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRenderingPdf(true);
|
||||
setStatus("Rendering packing slip PDF...");
|
||||
setActiveDocumentAction(kind);
|
||||
setStatus(
|
||||
kind === "packing-slip"
|
||||
? "Rendering packing slip PDF..."
|
||||
: kind === "label"
|
||||
? "Rendering shipping label PDF..."
|
||||
: "Rendering bill of lading PDF..."
|
||||
);
|
||||
try {
|
||||
const blob = await api.getShipmentPackingSlipPdf(token, shipment.id);
|
||||
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("Packing slip PDF rendered.");
|
||||
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 : "Unable to render packing slip PDF.";
|
||||
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 {
|
||||
setIsRenderingPdf(false);
|
||||
setActiveDocumentAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +119,14 @@ export function ShipmentDetailPage() {
|
||||
<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={handleOpenPackingSlip} disabled={isRenderingPdf} 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">
|
||||
{isRenderingPdf ? "Rendering PDF..." : "Open packing slip"}
|
||||
<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>
|
||||
@@ -168,6 +199,14 @@ export function ShipmentDetailPage() {
|
||||
</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."
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"projectNumber" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"priority" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"salesQuoteId" TEXT,
|
||||
"salesOrderId" TEXT,
|
||||
"shipmentId" TEXT,
|
||||
"ownerId" TEXT,
|
||||
"dueDate" DATETIME,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Project_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Project_salesQuoteId_fkey" FOREIGN KEY ("salesQuoteId") REFERENCES "SalesQuote" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Project_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Project_shipmentId_fkey" FOREIGN KEY ("shipmentId") REFERENCES "Shipment" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Project_projectNumber_key" ON "Project"("projectNumber");
|
||||
CREATE INDEX "Project_customerId_createdAt_idx" ON "Project"("customerId", "createdAt");
|
||||
CREATE INDEX "Project_ownerId_dueDate_idx" ON "Project"("ownerId", "dueDate");
|
||||
CREATE INDEX "Project_status_priority_idx" ON "Project"("status", "priority");
|
||||
@@ -21,6 +21,7 @@ model User {
|
||||
contactEntries CrmContactEntry[]
|
||||
inventoryTransactions InventoryTransaction[]
|
||||
purchaseReceipts PurchaseReceipt[]
|
||||
ownedProjects Project[] @relation("ProjectOwner")
|
||||
}
|
||||
|
||||
model Role {
|
||||
@@ -171,6 +172,7 @@ model Customer {
|
||||
childCustomers Customer[] @relation("CustomerHierarchy")
|
||||
salesQuotes SalesQuote[]
|
||||
salesOrders SalesOrder[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model InventoryBomLine {
|
||||
@@ -303,6 +305,7 @@ model SalesQuote {
|
||||
updatedAt DateTime @updatedAt
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
|
||||
lines SalesQuoteLine[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model SalesQuoteLine {
|
||||
@@ -337,6 +340,7 @@ model SalesOrder {
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
|
||||
lines SalesOrderLine[]
|
||||
shipments Shipment[]
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model SalesOrderLine {
|
||||
@@ -370,10 +374,37 @@ model Shipment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
|
||||
projects Project[]
|
||||
|
||||
@@index([salesOrderId, createdAt])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
projectNumber String @unique
|
||||
name String
|
||||
status String
|
||||
priority String
|
||||
customerId String
|
||||
salesQuoteId String?
|
||||
salesOrderId String?
|
||||
shipmentId String?
|
||||
ownerId String?
|
||||
dueDate DateTime?
|
||||
notes String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
|
||||
salesQuote SalesQuote? @relation(fields: [salesQuoteId], references: [id], onDelete: SetNull)
|
||||
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
|
||||
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
|
||||
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([customerId, createdAt])
|
||||
@@index([ownerId, dueDate])
|
||||
@@index([status, priority])
|
||||
}
|
||||
|
||||
model PurchaseOrder {
|
||||
id String @id @default(cuid())
|
||||
documentNumber String @unique
|
||||
|
||||
@@ -17,6 +17,7 @@ import { documentsRouter } from "./modules/documents/router.js";
|
||||
import { filesRouter } from "./modules/files/router.js";
|
||||
import { ganttRouter } from "./modules/gantt/router.js";
|
||||
import { inventoryRouter } from "./modules/inventory/router.js";
|
||||
import { projectsRouter } from "./modules/projects/router.js";
|
||||
import { purchasingRouter } from "./modules/purchasing/router.js";
|
||||
import { salesRouter } from "./modules/sales/router.js";
|
||||
import { shippingRouter } from "./modules/shipping/router.js";
|
||||
@@ -55,6 +56,7 @@ export function createApp() {
|
||||
app.use("/api/v1/files", filesRouter);
|
||||
app.use("/api/v1/crm", crmRouter);
|
||||
app.use("/api/v1/inventory", inventoryRouter);
|
||||
app.use("/api/v1/projects", projectsRouter);
|
||||
app.use("/api/v1/purchasing", purchasingRouter);
|
||||
app.use("/api/v1/sales", salesRouter);
|
||||
app.use("/api/v1/shipping", shippingRouter);
|
||||
|
||||
@@ -18,6 +18,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
|
||||
[permissions.ganttRead]: "View gantt timelines",
|
||||
[permissions.salesRead]: "View sales data",
|
||||
[permissions.salesWrite]: "Manage quotes and sales orders",
|
||||
[permissions.projectsRead]: "View projects and program records",
|
||||
[permissions.projectsWrite]: "Manage projects and program records",
|
||||
"purchasing.read": "View purchasing data",
|
||||
"purchasing.write": "Manage purchase orders",
|
||||
[permissions.shippingRead]: "View shipping data",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { renderPdf } from "../../lib/pdf.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import { getPurchaseOrderPdfData } from "../purchasing/service.js";
|
||||
import { getSalesDocumentPdfData } from "../sales/service.js";
|
||||
import { getShipmentPackingSlipData } from "../shipping/service.js";
|
||||
import { getShipmentDocumentData, getShipmentPackingSlipData } from "../shipping/service.js";
|
||||
import { getActiveCompanyProfile } from "../settings/service.js";
|
||||
|
||||
export const documentsRouter = Router();
|
||||
@@ -136,6 +136,205 @@ function renderCommercialDocumentPdf(options: {
|
||||
`);
|
||||
}
|
||||
|
||||
function buildShippingLabelPdf(options: {
|
||||
company: Awaited<ReturnType<typeof getActiveCompanyProfile>>;
|
||||
shipment: Awaited<ReturnType<typeof getShipmentDocumentData>>;
|
||||
}) {
|
||||
const { company, shipment } = options;
|
||||
if (!shipment) {
|
||||
throw new Error("Shipment data is required.");
|
||||
}
|
||||
|
||||
const shipToLines = buildAddressLines(shipment.customer);
|
||||
const topLine = shipment.lines[0];
|
||||
|
||||
return renderPdf(`
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
@page { size: 4in 6in; margin: 8mm; }
|
||||
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; }
|
||||
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: calc(6in - 16mm); box-sizing: border-box; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
|
||||
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; }
|
||||
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
|
||||
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 4px; }
|
||||
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
|
||||
.strong { font-weight: 700; }
|
||||
.big { font-size: 16px; font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">
|
||||
<div class="brand">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="muted">From</div>
|
||||
<h1>${escapeHtml(company.companyName)}</h1>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="muted">Shipment</div>
|
||||
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<div class="muted">Ship To</div>
|
||||
<div class="stack" style="margin-top:8px;">
|
||||
${shipToLines.map((line) => `<div class="${line === shipment.customer.name ? "strong" : ""}">${escapeHtml(line)}</div>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="block" style="flex:1;">
|
||||
<div class="muted">Service</div>
|
||||
<div class="big" style="margin-top:6px;">${escapeHtml(shipment.serviceLevel || "GROUND")}</div>
|
||||
</div>
|
||||
<div class="block" style="width:90px;">
|
||||
<div class="muted">Pkgs</div>
|
||||
<div class="big" style="margin-top:6px;">${shipment.packageCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="block" style="flex:1;">
|
||||
<div class="muted">Sales Order</div>
|
||||
<div class="strong" style="margin-top:6px;">${escapeHtml(shipment.salesOrderNumber)}</div>
|
||||
</div>
|
||||
<div class="block" style="width:110px;">
|
||||
<div class="muted">Ship Date</div>
|
||||
<div class="strong" style="margin-top:6px;">${escapeHtml(formatDate(shipment.shipDate))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<div class="muted">Reference</div>
|
||||
<div style="margin-top:6px;">${escapeHtml(topLine ? `${topLine.itemSku} · ${topLine.itemName}` : "Shipment record")}</div>
|
||||
</div>
|
||||
<div class="barcode">
|
||||
*${escapeHtml(shipment.trackingNumber || shipment.shipmentNumber)}*
|
||||
</div>
|
||||
<div style="text-align:center; font-size:10px; color:#4b5563;">${escapeHtml(shipment.carrier || "Carrier pending")} · ${escapeHtml(shipment.trackingNumber || "Tracking pending")}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
function buildBillOfLadingPdf(options: {
|
||||
company: Awaited<ReturnType<typeof getActiveCompanyProfile>>;
|
||||
shipment: Awaited<ReturnType<typeof getShipmentDocumentData>>;
|
||||
}) {
|
||||
const { company, shipment } = options;
|
||||
if (!shipment) {
|
||||
throw new Error("Shipment data is required.");
|
||||
}
|
||||
|
||||
const shipperLines = [
|
||||
company.companyName,
|
||||
company.addressLine1,
|
||||
company.addressLine2,
|
||||
`${company.city}, ${company.state} ${company.postalCode}`.trim(),
|
||||
company.country,
|
||||
company.phone,
|
||||
company.email,
|
||||
].filter((line) => line.trim().length > 0);
|
||||
const consigneeLines = [
|
||||
shipment.customer.name,
|
||||
shipment.customer.addressLine1,
|
||||
shipment.customer.addressLine2,
|
||||
`${shipment.customer.city}, ${shipment.customer.state} ${shipment.customer.postalCode}`.trim(),
|
||||
shipment.customer.country,
|
||||
shipment.customerPhone,
|
||||
shipment.customerEmail,
|
||||
].filter((line) => line.trim().length > 0);
|
||||
const totalQuantity = shipment.lines.reduce((sum, line) => sum + line.quantity, 0);
|
||||
const rows = shipment.lines.map((line) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(line.itemSku)}</td>
|
||||
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
|
||||
<td class="number">${line.quantity}</td>
|
||||
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return renderPdf(`
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
@page { margin: 16mm; }
|
||||
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; font-size: 12px; }
|
||||
.page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.header { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 16px; }
|
||||
.brand h1 { margin: 0; font-size: 24px; color: ${company.theme.primaryColor}; }
|
||||
.brand p { margin: 6px 0 0; color: #5a6a85; line-height: 1.45; }
|
||||
.meta { min-width: 320px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
|
||||
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
|
||||
.value { margin-top: 4px; font-size: 13px; font-weight: 600; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.card { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; }
|
||||
.card-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; margin-bottom: 8px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 4px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; background: #f4f7fb; padding: 10px 12px; border-bottom: 1px solid #d7deeb; }
|
||||
tbody td { padding: 12px; border-bottom: 1px solid #e6ebf3; vertical-align: top; }
|
||||
.number { text-align: right; white-space: nowrap; }
|
||||
.item-name { font-weight: 600; }
|
||||
.item-desc { margin-top: 4px; color: #5a6a85; font-size: 11px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.summary-card { border: 1px solid #d7deeb; border-radius: 14px; padding: 12px 14px; }
|
||||
.notes { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; min-height: 72px; white-space: pre-line; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<h1>${escapeHtml(company.companyName)}</h1>
|
||||
<p>Bill of Lading</p>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div><div class="label">Shipment</div><div class="value">${escapeHtml(shipment.shipmentNumber)}</div></div>
|
||||
<div><div class="label">Sales Order</div><div class="value">${escapeHtml(shipment.salesOrderNumber)}</div></div>
|
||||
<div><div class="label">Ship Date</div><div class="value">${escapeHtml(formatDate(shipment.shipDate))}</div></div>
|
||||
<div><div class="label">Status</div><div class="value">${escapeHtml(shipment.status)}</div></div>
|
||||
<div><div class="label">Carrier</div><div class="value">${escapeHtml(shipment.carrier || "Not set")}</div></div>
|
||||
<div><div class="label">Tracking</div><div class="value">${escapeHtml(shipment.trackingNumber || "Not set")}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card-title">Shipper</div>
|
||||
<div class="stack">${shipperLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Consignee</div>
|
||||
<div class="stack">${consigneeLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary">
|
||||
<div class="summary-card"><div class="label">Packages</div><div class="value">${shipment.packageCount}</div></div>
|
||||
<div class="summary-card"><div class="label">Line Count</div><div class="value">${shipment.lines.length}</div></div>
|
||||
<div class="summary-card"><div class="label">Total Qty</div><div class="value">${totalQuantity}</div></div>
|
||||
<div class="summary-card"><div class="label">Service</div><div class="value">${escapeHtml(shipment.serviceLevel || "Not set")}</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 18%;">SKU</th>
|
||||
<th>Description</th>
|
||||
<th style="width: 12%;" class="number">Qty</th>
|
||||
<th style="width: 10%;" class="number">UOM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<div class="notes"><div class="card-title">Logistics Notes</div>${escapeHtml(shipment.notes || "No shipment notes recorded.")}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissions.companyRead]), async (_request, response) => {
|
||||
const profile = await getActiveCompanyProfile();
|
||||
const pdf = await renderPdf(`
|
||||
@@ -471,3 +670,49 @@ documentsRouter.get(
|
||||
return response.send(pdf);
|
||||
}
|
||||
);
|
||||
|
||||
documentsRouter.get(
|
||||
"/shipping/shipments/:shipmentId/shipping-label.pdf",
|
||||
requirePermissions([permissions.shippingRead]),
|
||||
async (request, response) => {
|
||||
const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null;
|
||||
if (!shipmentId) {
|
||||
response.status(400);
|
||||
return response.send("Invalid shipment id.");
|
||||
}
|
||||
|
||||
const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentDocumentData(shipmentId)]);
|
||||
if (!shipment) {
|
||||
response.status(404);
|
||||
return response.send("Shipment was not found.");
|
||||
}
|
||||
|
||||
const pdf = await buildShippingLabelPdf({ company: profile, shipment });
|
||||
response.setHeader("Content-Type", "application/pdf");
|
||||
response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-label.pdf`);
|
||||
return response.send(pdf);
|
||||
}
|
||||
);
|
||||
|
||||
documentsRouter.get(
|
||||
"/shipping/shipments/:shipmentId/bill-of-lading.pdf",
|
||||
requirePermissions([permissions.shippingRead]),
|
||||
async (request, response) => {
|
||||
const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null;
|
||||
if (!shipmentId) {
|
||||
response.status(400);
|
||||
return response.send("Invalid shipment id.");
|
||||
}
|
||||
|
||||
const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentDocumentData(shipmentId)]);
|
||||
if (!shipment) {
|
||||
response.status(404);
|
||||
return response.send("Shipment was not found.");
|
||||
}
|
||||
|
||||
const pdf = await buildBillOfLadingPdf({ company: profile, shipment });
|
||||
response.setHeader("Content-Type", "application/pdf");
|
||||
response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-bill-of-lading.pdf`);
|
||||
return response.send(pdf);
|
||||
}
|
||||
);
|
||||
|
||||
139
server/src/modules/projects/router.ts
Normal file
139
server/src/modules/projects/router.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { permissions, projectPriorities, projectStatuses } from "@mrp/shared";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import {
|
||||
createProject,
|
||||
getProjectById,
|
||||
listProjectCustomerOptions,
|
||||
listProjectOrderOptions,
|
||||
listProjectOwnerOptions,
|
||||
listProjects,
|
||||
listProjectQuoteOptions,
|
||||
listProjectShipmentOptions,
|
||||
updateProject,
|
||||
} from "./service.js";
|
||||
|
||||
const projectSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
status: z.enum(projectStatuses),
|
||||
priority: z.enum(projectPriorities),
|
||||
customerId: z.string().trim().min(1),
|
||||
salesQuoteId: z.string().trim().min(1).nullable(),
|
||||
salesOrderId: z.string().trim().min(1).nullable(),
|
||||
shipmentId: z.string().trim().min(1).nullable(),
|
||||
ownerId: z.string().trim().min(1).nullable(),
|
||||
dueDate: z.string().datetime().nullable(),
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
const projectListQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
status: z.enum(projectStatuses).optional(),
|
||||
priority: z.enum(projectPriorities).optional(),
|
||||
customerId: z.string().optional(),
|
||||
ownerId: z.string().optional(),
|
||||
});
|
||||
|
||||
const projectOptionQuerySchema = z.object({
|
||||
customerId: z.string().optional(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export const projectsRouter = Router();
|
||||
|
||||
projectsRouter.get("/customers/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
|
||||
return ok(response, await listProjectCustomerOptions());
|
||||
});
|
||||
|
||||
projectsRouter.get("/owners/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
|
||||
return ok(response, await listProjectOwnerOptions());
|
||||
});
|
||||
|
||||
projectsRouter.get("/quotes/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
||||
const parsed = projectOptionQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project quote filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await listProjectQuoteOptions(parsed.data.customerId));
|
||||
});
|
||||
|
||||
projectsRouter.get("/orders/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
||||
const parsed = projectOptionQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project order filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await listProjectOrderOptions(parsed.data.customerId));
|
||||
});
|
||||
|
||||
projectsRouter.get("/shipments/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
||||
const parsed = projectOptionQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project shipment filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await listProjectShipmentOptions(parsed.data.customerId));
|
||||
});
|
||||
|
||||
projectsRouter.get("/", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
||||
const parsed = projectListQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await listProjects(parsed.data));
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
||||
const projectId = getRouteParam(request.params.projectId);
|
||||
if (!projectId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
|
||||
}
|
||||
|
||||
const project = await getProjectById(projectId);
|
||||
if (!project) {
|
||||
return fail(response, 404, "PROJECT_NOT_FOUND", "Project was not found.");
|
||||
}
|
||||
|
||||
return ok(response, project);
|
||||
});
|
||||
|
||||
projectsRouter.post("/", requirePermissions([permissions.projectsWrite]), async (request, response) => {
|
||||
const parsed = projectSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createProject(parsed.data);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.project, 201);
|
||||
});
|
||||
|
||||
projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]), async (request, response) => {
|
||||
const projectId = getRouteParam(request.params.projectId);
|
||||
if (!projectId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = projectSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateProject(projectId, parsed.data);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.project);
|
||||
});
|
||||
425
server/src/modules/projects/service.ts
Normal file
425
server/src/modules/projects/service.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import type {
|
||||
ProjectCustomerOptionDto,
|
||||
ProjectDetailDto,
|
||||
ProjectDocumentOptionDto,
|
||||
ProjectInput,
|
||||
ProjectOwnerOptionDto,
|
||||
ProjectPriority,
|
||||
ProjectShipmentOptionDto,
|
||||
ProjectStatus,
|
||||
ProjectSummaryDto,
|
||||
} from "@mrp/shared";
|
||||
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const projectModel = (prisma as any).project;
|
||||
|
||||
type ProjectRecord = {
|
||||
id: string;
|
||||
projectNumber: string;
|
||||
name: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
customer: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
owner: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} | null;
|
||||
salesQuote: {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
} | null;
|
||||
salesOrder: {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
} | null;
|
||||
shipment: {
|
||||
id: string;
|
||||
shipmentNumber: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function getOwnerName(owner: ProjectRecord["owner"]) {
|
||||
return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null;
|
||||
}
|
||||
|
||||
function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
projectNumber: record.projectNumber,
|
||||
name: record.name,
|
||||
status: record.status as ProjectStatus,
|
||||
priority: record.priority as ProjectPriority,
|
||||
customerId: record.customer.id,
|
||||
customerName: record.customer.name,
|
||||
ownerId: record.owner?.id ?? null,
|
||||
ownerName: getOwnerName(record.owner),
|
||||
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
|
||||
return {
|
||||
...mapProjectSummary(record),
|
||||
notes: record.notes,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
salesQuoteId: record.salesQuote?.id ?? null,
|
||||
salesQuoteNumber: record.salesQuote?.documentNumber ?? null,
|
||||
salesOrderId: record.salesOrder?.id ?? null,
|
||||
salesOrderNumber: record.salesOrder?.documentNumber ?? null,
|
||||
shipmentId: record.shipment?.id ?? null,
|
||||
shipmentNumber: record.shipment?.shipmentNumber ?? null,
|
||||
customerEmail: record.customer.email,
|
||||
customerPhone: record.customer.phone,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInclude() {
|
||||
return {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
salesQuote: {
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
},
|
||||
},
|
||||
salesOrder: {
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
},
|
||||
},
|
||||
shipment: {
|
||||
select: {
|
||||
id: true,
|
||||
shipmentNumber: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function nextProjectNumber() {
|
||||
const next = (await projectModel.count()) + 1;
|
||||
return `PRJ-${String(next).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
async function validateProjectInput(payload: ProjectInput) {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: payload.customerId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
return { ok: false as const, reason: "Customer was not found." };
|
||||
}
|
||||
|
||||
if (payload.ownerId) {
|
||||
const owner = await prisma.user.findUnique({
|
||||
where: { id: payload.ownerId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!owner?.isActive) {
|
||||
return { ok: false as const, reason: "Project owner was not found." };
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.salesQuoteId) {
|
||||
const quote = await prisma.salesQuote.findUnique({
|
||||
where: { id: payload.salesQuoteId },
|
||||
select: { id: true, customerId: true },
|
||||
});
|
||||
|
||||
if (!quote) {
|
||||
return { ok: false as const, reason: "Linked quote was not found." };
|
||||
}
|
||||
|
||||
if (quote.customerId !== payload.customerId) {
|
||||
return { ok: false as const, reason: "Linked quote must belong to the selected customer." };
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.salesOrderId) {
|
||||
const order = await prisma.salesOrder.findUnique({
|
||||
where: { id: payload.salesOrderId },
|
||||
select: { id: true, customerId: true },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return { ok: false as const, reason: "Linked sales order was not found." };
|
||||
}
|
||||
|
||||
if (order.customerId !== payload.customerId) {
|
||||
return { ok: false as const, reason: "Linked sales order must belong to the selected customer." };
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.shipmentId) {
|
||||
const shipment = await prisma.shipment.findUnique({
|
||||
where: { id: payload.shipmentId },
|
||||
include: {
|
||||
salesOrder: {
|
||||
select: {
|
||||
customerId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!shipment) {
|
||||
return { ok: false as const, reason: "Linked shipment was not found." };
|
||||
}
|
||||
|
||||
if (shipment.salesOrder.customerId !== payload.customerId) {
|
||||
return { ok: false as const, reason: "Linked shipment must belong to the selected customer." };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function listProjectCustomerOptions(): Promise<ProjectCustomerOptionDto[]> {
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: {
|
||||
status: {
|
||||
not: "INACTIVE",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
});
|
||||
|
||||
return customers;
|
||||
}
|
||||
|
||||
export async function listProjectOwnerOptions(): Promise<ProjectOwnerOptionDto[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: [{ firstName: "asc" }, { lastName: "asc" }],
|
||||
});
|
||||
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
fullName: `${user.firstName} ${user.lastName}`.trim(),
|
||||
email: user.email,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listProjectQuoteOptions(customerId?: string | null): Promise<ProjectDocumentOptionDto[]> {
|
||||
const quotes = await prisma.salesQuote.findMany({
|
||||
where: {
|
||||
...(customerId ? { customerId } : {}),
|
||||
},
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return quotes.map((quote) => ({
|
||||
id: quote.id,
|
||||
documentNumber: quote.documentNumber,
|
||||
customerName: quote.customer.name,
|
||||
status: quote.status,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listProjectOrderOptions(customerId?: string | null): Promise<ProjectDocumentOptionDto[]> {
|
||||
const orders = await prisma.salesOrder.findMany({
|
||||
where: {
|
||||
...(customerId ? { customerId } : {}),
|
||||
},
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return orders.map((order) => ({
|
||||
id: order.id,
|
||||
documentNumber: order.documentNumber,
|
||||
customerName: order.customer.name,
|
||||
status: order.status,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listProjectShipmentOptions(customerId?: string | null): Promise<ProjectShipmentOptionDto[]> {
|
||||
const shipments = await prisma.shipment.findMany({
|
||||
where: {
|
||||
...(customerId ? { salesOrder: { customerId } } : {}),
|
||||
},
|
||||
include: {
|
||||
salesOrder: {
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return shipments.map((shipment) => ({
|
||||
id: shipment.id,
|
||||
shipmentNumber: shipment.shipmentNumber,
|
||||
salesOrderNumber: shipment.salesOrder.documentNumber,
|
||||
customerName: shipment.salesOrder.customer.name,
|
||||
status: shipment.status,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listProjects(filters: {
|
||||
q?: string;
|
||||
status?: ProjectStatus;
|
||||
priority?: ProjectPriority;
|
||||
customerId?: string;
|
||||
ownerId?: string;
|
||||
} = {}) {
|
||||
const query = filters.q?.trim();
|
||||
const projects = await projectModel.findMany({
|
||||
where: {
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.priority ? { priority: filters.priority } : {}),
|
||||
...(filters.customerId ? { customerId: filters.customerId } : {}),
|
||||
...(filters.ownerId ? { ownerId: filters.ownerId } : {}),
|
||||
...(query
|
||||
? {
|
||||
OR: [
|
||||
{ projectNumber: { contains: query } },
|
||||
{ name: { contains: query } },
|
||||
{ customer: { name: { contains: query } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: buildInclude(),
|
||||
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
|
||||
});
|
||||
|
||||
return projects.map((project: unknown) => mapProjectSummary(project as ProjectRecord));
|
||||
}
|
||||
|
||||
export async function getProjectById(projectId: string) {
|
||||
const project = await projectModel.findUnique({
|
||||
where: { id: projectId },
|
||||
include: buildInclude(),
|
||||
});
|
||||
|
||||
return project ? mapProjectDetail(project as ProjectRecord) : null;
|
||||
}
|
||||
|
||||
export async function createProject(payload: ProjectInput) {
|
||||
const validated = await validateProjectInput(payload);
|
||||
if (!validated.ok) {
|
||||
return { ok: false as const, reason: validated.reason };
|
||||
}
|
||||
|
||||
const projectNumber = await nextProjectNumber();
|
||||
const created = await projectModel.create({
|
||||
data: {
|
||||
projectNumber,
|
||||
name: payload.name.trim(),
|
||||
status: payload.status,
|
||||
priority: payload.priority,
|
||||
customerId: payload.customerId,
|
||||
salesQuoteId: payload.salesQuoteId,
|
||||
salesOrderId: payload.salesOrderId,
|
||||
shipmentId: payload.shipmentId,
|
||||
ownerId: payload.ownerId,
|
||||
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
|
||||
notes: payload.notes,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const project = await getProjectById(created.id);
|
||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||
}
|
||||
|
||||
export async function updateProject(projectId: string, payload: ProjectInput) {
|
||||
const existing = await projectModel.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return { ok: false as const, reason: "Project was not found." };
|
||||
}
|
||||
|
||||
const validated = await validateProjectInput(payload);
|
||||
if (!validated.ok) {
|
||||
return { ok: false as const, reason: validated.reason };
|
||||
}
|
||||
|
||||
await projectModel.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
status: payload.status,
|
||||
priority: payload.priority,
|
||||
customerId: payload.customerId,
|
||||
salesQuoteId: payload.salesQuoteId,
|
||||
salesOrderId: payload.salesOrderId,
|
||||
shipmentId: payload.shipmentId,
|
||||
ownerId: payload.ownerId,
|
||||
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
|
||||
notes: payload.notes,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const project = await getProjectById(projectId);
|
||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||
}
|
||||
@@ -36,6 +36,12 @@ export interface ShipmentPackingSlipData {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ShipmentDocumentData extends ShipmentPackingSlipData {
|
||||
salesOrderId: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
}
|
||||
|
||||
type ShipmentRecord = {
|
||||
id: string;
|
||||
shipmentNumber: string;
|
||||
@@ -251,6 +257,16 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
|
||||
}
|
||||
|
||||
export async function getShipmentPackingSlipData(shipmentId: string): Promise<ShipmentPackingSlipData | null> {
|
||||
const shipment = await getShipmentDocumentData(shipmentId);
|
||||
|
||||
if (!shipment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return shipment;
|
||||
}
|
||||
|
||||
export async function getShipmentDocumentData(shipmentId: string): Promise<ShipmentDocumentData | null> {
|
||||
const shipment = await prisma.shipment.findUnique({
|
||||
where: { id: shipmentId },
|
||||
include: {
|
||||
@@ -259,6 +275,8 @@ export async function getShipmentPackingSlipData(shipmentId: string): Promise<Sh
|
||||
customer: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
city: true,
|
||||
@@ -288,6 +306,7 @@ export async function getShipmentPackingSlipData(shipmentId: string): Promise<Sh
|
||||
}
|
||||
|
||||
return {
|
||||
salesOrderId: shipment.salesOrder.id,
|
||||
shipmentNumber: shipment.shipmentNumber,
|
||||
status: shipment.status as ShipmentStatus,
|
||||
shipDate: shipment.shipDate ? shipment.shipDate.toISOString() : null,
|
||||
@@ -297,6 +316,8 @@ export async function getShipmentPackingSlipData(shipmentId: string): Promise<Sh
|
||||
packageCount: shipment.packageCount,
|
||||
notes: shipment.notes,
|
||||
salesOrderNumber: shipment.salesOrder.documentNumber,
|
||||
customerEmail: shipment.salesOrder.customer.email,
|
||||
customerPhone: shipment.salesOrder.customer.phone,
|
||||
customer: shipment.salesOrder.customer,
|
||||
lines: shipment.salesOrder.lines.map((line) => ({
|
||||
itemSku: line.item.sku,
|
||||
|
||||
@@ -11,6 +11,8 @@ export const permissions = {
|
||||
ganttRead: "gantt.read",
|
||||
salesRead: "sales.read",
|
||||
salesWrite: "sales.write",
|
||||
projectsRead: "projects.read",
|
||||
projectsWrite: "projects.write",
|
||||
purchasingRead: "purchasing.read",
|
||||
purchasingWrite: "purchasing.write",
|
||||
shippingRead: "shipping.read",
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./crm/types.js";
|
||||
export * from "./files/types.js";
|
||||
export * from "./gantt/types.js";
|
||||
export * from "./inventory/types.js";
|
||||
export * from "./projects/types.js";
|
||||
export * from "./purchasing/types.js";
|
||||
export * from "./sales/types.js";
|
||||
export * from "./shipping/types.js";
|
||||
|
||||
72
shared/src/projects/types.ts
Normal file
72
shared/src/projects/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export const projectStatuses = ["PLANNED", "ACTIVE", "ON_HOLD", "AT_RISK", "COMPLETE"] as const;
|
||||
export const projectPriorities = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] as const;
|
||||
|
||||
export type ProjectStatus = (typeof projectStatuses)[number];
|
||||
export type ProjectPriority = (typeof projectPriorities)[number];
|
||||
|
||||
export interface ProjectCustomerOptionDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ProjectDocumentOptionDto {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
customerName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProjectShipmentOptionDto {
|
||||
id: string;
|
||||
shipmentNumber: string;
|
||||
salesOrderNumber: string;
|
||||
customerName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProjectOwnerOptionDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ProjectSummaryDto {
|
||||
id: string;
|
||||
projectNumber: string;
|
||||
name: string;
|
||||
status: ProjectStatus;
|
||||
priority: ProjectPriority;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
ownerId: string | null;
|
||||
ownerName: string | null;
|
||||
dueDate: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectDetailDto extends ProjectSummaryDto {
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
salesQuoteId: string | null;
|
||||
salesQuoteNumber: string | null;
|
||||
salesOrderId: string | null;
|
||||
salesOrderNumber: string | null;
|
||||
shipmentId: string | null;
|
||||
shipmentNumber: string | null;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
}
|
||||
|
||||
export interface ProjectInput {
|
||||
name: string;
|
||||
status: ProjectStatus;
|
||||
priority: ProjectPriority;
|
||||
customerId: string;
|
||||
salesQuoteId: string | null;
|
||||
salesOrderId: string | null;
|
||||
shipmentId: string | null;
|
||||
ownerId: string | null;
|
||||
dueDate: string | null;
|
||||
notes: string;
|
||||
}
|
||||
Reference in New Issue
Block a user