From f772ccacc7564233c9e6d00a51791e9b8e540b77 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 17 Mar 2026 19:13:54 -0500 Subject: [PATCH] project cockpit --- CHANGELOG.md | 2 +- README.md | 4 +- ROADMAP.md | 4 +- SHIPPED.md | 2 +- .../modules/projects/ProjectDetailPage.tsx | 298 ++++---------- server/src/modules/projects/service.ts | 379 +++++++++++++++++- shared/src/projects/types.ts | 90 +++++ 7 files changed, 550 insertions(+), 229 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4acbe1..ff357b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh ### Added -- Project cockpit section on project detail pages for commercial, supply, execution, and delivery rollups +- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, and readiness-risk rollups - Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow - Project-side milestone and work-order rollups surfaced on project list and detail pages - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form diff --git a/README.md b/README.md index cbe5c78..87f7ee1 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Navigation direction: ## Projects Direction -Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery visibility, notes, commercial document links, shipment links, attachments, and dashboard visibility. +Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery/purchasing visibility, readiness-risk scoring, notes, commercial document links, shipment links, attachments, and dashboard visibility. Current interactions: @@ -104,7 +104,7 @@ Current interactions: 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 +- Purchasing: project material demand is now visible through linked PO, receipt, vendor, and outstanding-supply rollups, and should later expand into project-side purchasing actions - 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 diff --git a/ROADMAP.md b/ROADMAP.md index bde4e31..cc2d091 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,11 +76,11 @@ This file tracks work that still needs to be completed. Shipped phase history an - Project document hub for drawings, support files, correspondence, and revision references - Non-manufacturing work packages for long-running execution tracking -- Deeper project-level commercial, material, schedule, and delivery rollups +- Deeper project-level cost, material, schedule, and delivery rollups beyond the current purchasing/readiness cockpit - Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication - Project templates for repeatable build types - Project-specific attachment bundles and revision snapshots -- One-screen project cockpit with commercial, material, schedule, and shipping summary +- One-screen project cockpit with deeper cost, material, schedule, shipping, and action-oriented summary workflows - Better cross-links between project, customer, order, shipment, and inventory records - Project filtering by customer, owner, status, due date, and risk - Project activity timeline and audit-friendly milestone history diff --git a/SHIPPED.md b/SHIPPED.md index 5598653..2c41a17 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -34,7 +34,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Logistics attachments directly on shipment records - Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage - Project milestones and project-side milestone/work-order rollups -- Project cockpit section on detail pages for commercial, supply, execution, and delivery visibility +- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, and readiness-risk visibility - Project list/detail/create/edit workflows and dashboard program widgets - Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments - Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling diff --git a/client/src/modules/projects/ProjectDetailPage.tsx b/client/src/modules/projects/ProjectDetailPage.tsx index 48b482a..e61068e 100644 --- a/client/src/modules/projects/ProjectDetailPage.tsx +++ b/client/src/modules/projects/ProjectDetailPage.tsx @@ -1,28 +1,27 @@ import { permissions } from "@mrp/shared"; +import type { WorkOrderSummaryDto } from "@mrp/shared"; import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js"; import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js"; -import type { SalesDocumentDetailDto } from "@mrp/shared/dist/sales/types.js"; -import type { ShipmentDetailDto } from "@mrp/shared/dist/shipping/types.js"; -import type { WorkOrderSummaryDto } from "@mrp/shared"; import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel"; import { useAuth } from "../../auth/AuthProvider"; +import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel"; import { api, ApiError } from "../../lib/api"; import { projectMilestoneStatusPalette } from "./config"; import { ProjectPriorityBadge } from "./ProjectPriorityBadge"; import { ProjectStatusBadge } from "./ProjectStatusBadge"; +function formatCurrency(value: number | null) { + return value === null ? "Not linked" : `$${value.toFixed(2)}`; +} + export function ProjectDetailPage() { const { token, user } = useAuth(); const { projectId } = useParams(); const [project, setProject] = useState(null); const [workOrders, setWorkOrders] = useState([]); const [planning, setPlanning] = useState(null); - const [quote, setQuote] = useState(null); - const [salesOrder, setSalesOrder] = useState(null); - const [shipment, setShipment] = useState(null); const [status, setStatus] = useState("Loading project..."); const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false; @@ -36,19 +35,13 @@ export function ProjectDetailPage() { .then(async (nextProject) => { setProject(nextProject); setStatus("Project loaded."); - const [nextPlanning, nextWorkOrders, nextQuote, nextSalesOrder, nextShipment] = await Promise.all([ + const [nextPlanning, nextWorkOrders] = await Promise.all([ nextProject.salesOrderId ? api.getSalesOrderPlanning(token, nextProject.salesOrderId).catch(() => null) : Promise.resolve(null), api.getWorkOrders(token, { projectId: nextProject.id }), - nextProject.salesQuoteId ? api.getQuote(token, nextProject.salesQuoteId).catch(() => null) : Promise.resolve(null), - nextProject.salesOrderId ? api.getSalesOrder(token, nextProject.salesOrderId).catch(() => null) : Promise.resolve(null), - nextProject.shipmentId ? api.getShipment(token, nextProject.shipmentId).catch(() => null) : Promise.resolve(null), ]); setPlanning(nextPlanning); setWorkOrders(nextWorkOrders); - setQuote(nextQuote); - setSalesOrder(nextSalesOrder); - setShipment(nextShipment); }) .catch((error: unknown) => { const message = error instanceof ApiError ? error.message : "Unable to load project."; @@ -80,8 +73,11 @@ export function ProjectDetailPage() { return left.sortOrder - right.sortOrder; }); + const nextMilestone = sortedMilestones.find((milestone) => milestone.status !== "COMPLETE") ?? null; - const activeWorkOrders = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD"); + const activeWorkOrders = workOrders.filter( + (workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD" + ); const nextWorkOrder = [...activeWorkOrders] .sort((left, right) => { if (left.dueDate && right.dueDate) { @@ -93,6 +89,7 @@ export function ProjectDetailPage() { if (right.dueDate) { return 1; } + return left.workOrderNumber.localeCompare(right.workOrderNumber); })[0] ?? null; const materialExceptionItems = planning @@ -101,6 +98,12 @@ export function ProjectDetailPage() { const completionPercent = project.rollups.milestoneCount > 0 ? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100) : 0; + const readinessScore = project.cockpit.risk.readinessScore; + const riskTone = project.cockpit.risk.riskLevel === "LOW" + ? "text-emerald-600 dark:text-emerald-300" + : project.cockpit.risk.riskLevel === "MEDIUM" + ? "text-amber-600 dark:text-amber-300" + : "text-rose-600 dark:text-rose-300"; return (
@@ -128,32 +131,17 @@ export function ProjectDetailPage() {

Created

{new Date(project.createdAt).toLocaleDateString()}
-
-

Milestones

-
{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}
-
{project.rollups.openMilestoneCount} open
-
-
-

Overdue Milestones

-
{project.rollups.overdueMilestoneCount}
-
-
-

Linked Work Orders

-
{project.rollups.workOrderCount}
-
{project.rollups.activeWorkOrderCount} active
-
-
-

Overdue Work Orders

-
{project.rollups.overdueWorkOrderCount}
-
{project.rollups.completedWorkOrderCount} complete
-
+

Milestones

{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}
{project.rollups.openMilestoneCount} open
+

Overdue Milestones

{project.rollups.overdueMilestoneCount}
+

Linked Work Orders

{project.rollups.workOrderCount}
{project.rollups.activeWorkOrderCount} active
+

Overdue Work Orders

{project.rollups.overdueWorkOrderCount}
{project.rollups.completedWorkOrderCount} complete

Project Cockpit

Cross-functional execution view

-

Commercial, supply, execution, and delivery signals for this program in one place.

+

Commercial, supply, execution, purchasing, and delivery signals for this program in one place.

Milestone Progress
@@ -161,87 +149,55 @@ export function ProjectDetailPage() {
-
-

Commercial

-
- {salesOrder ? `$${salesOrder.total.toFixed(2)}` : quote ? `$${quote.total.toFixed(2)}` : "Not linked"} -
-
- {salesOrder ? `${salesOrder.documentNumber} · ${salesOrder.status}` : quote ? `${quote.documentNumber} · ${quote.status}` : "Link a quote or sales order"} -
-
-
-

Supply

-
{planning ? planning.summary.uncoveredItemCount : 0} shortage items
-
- {planning ? `Build ${planning.summary.totalBuildQuantity} · Buy ${planning.summary.totalPurchaseQuantity}` : "No sales-order planning linked"} -
-
-
-

Execution

-
{project.rollups.activeWorkOrderCount} active work orders
-
- {nextWorkOrder ? `${nextWorkOrder.workOrderNumber} due ${nextWorkOrder.dueDate ? new Date(nextWorkOrder.dueDate).toLocaleDateString() : "unscheduled"}` : "No active work order due date"} -
-
-
-

Delivery

-
{shipment ? shipment.status.replace("_", " ") : "Not linked"}
-
- {shipment ? `${shipment.shipmentNumber} · ${shipment.packageCount} package(s)` : "Link a shipment to track delivery"} -
-
+

Commercial

{formatCurrency(project.cockpit.commercial.activeDocumentTotal)}
{project.cockpit.commercial.activeDocumentNumber ? `${project.cockpit.commercial.activeDocumentNumber} - ${project.cockpit.commercial.activeDocumentStatus}` : "Link a quote or sales order"}
+

Supply

{planning ? planning.summary.uncoveredItemCount : project.cockpit.risk.shortageItemCount} shortage items
{planning ? `Build ${planning.summary.totalBuildQuantity} - Buy ${planning.summary.totalPurchaseQuantity}` : `Uncovered qty ${project.cockpit.risk.totalUncoveredQuantity}`}
+

Execution

{project.rollups.activeWorkOrderCount} active work orders
{nextWorkOrder ? `${nextWorkOrder.workOrderNumber} due ${nextWorkOrder.dueDate ? new Date(nextWorkOrder.dueDate).toLocaleDateString() : "unscheduled"}` : "No active work order due date"}
+

Delivery

{project.cockpit.delivery.shipmentStatus ? project.cockpit.delivery.shipmentStatus.replaceAll("_", " ") : "Not linked"}
{project.cockpit.delivery.shipmentNumber ? `${project.cockpit.delivery.shipmentNumber} - ${project.cockpit.delivery.packageCount} package(s)` : "Link a shipment to track delivery"}
+
+
+

Purchasing Coverage

{project.cockpit.purchasing.totalReceivedQuantity}/{project.cockpit.purchasing.totalOrderedQuantity} received
{project.cockpit.purchasing.linkedPurchaseOrderCount} linked PO(s) - {project.cockpit.purchasing.totalOutstandingQuantity} outstanding
+

Readiness Score

{readinessScore}%
{project.cockpit.risk.riskLevel} risk - {project.cockpit.risk.shortageItemCount} shortage item(s)
+

Material Spend

${project.cockpit.purchasing.linkedLineValue.toFixed(2)}
{project.cockpit.purchasing.vendorCount} vendor(s) across {project.cockpit.purchasing.linkedLineCount} linked line(s)

Next Checkpoints

-
-
Milestone
-
{nextMilestone ? nextMilestone.title : "All milestones complete"}
-
- {nextMilestone - ? `${nextMilestone.status.replace("_", " ")} · ${nextMilestone.dueDate ? new Date(nextMilestone.dueDate).toLocaleDateString() : "No due date"}` - : "No open milestone remains."} -
-
-
-
Work Order
-
{nextWorkOrder ? nextWorkOrder.workOrderNumber : "No active work orders"}
-
- {nextWorkOrder - ? `${nextWorkOrder.itemSku} · ${nextWorkOrder.completedQuantity}/${nextWorkOrder.quantity} complete` - : "Launch or link a work order to populate execution checkpoints."} -
-
+
Milestone
{nextMilestone ? nextMilestone.title : "All milestones complete"}
{nextMilestone ? `${nextMilestone.status.replace("_", " ")} - ${nextMilestone.dueDate ? new Date(nextMilestone.dueDate).toLocaleDateString() : "No due date"}` : "No open milestone remains."}
+
Work Order
{nextWorkOrder ? nextWorkOrder.workOrderNumber : "No active work orders"}
{nextWorkOrder ? `${nextWorkOrder.itemSku} - ${nextWorkOrder.completedQuantity}/${nextWorkOrder.quantity} complete` : "Launch or link a work order to populate execution checkpoints."}

Material Watchlist

- {materialExceptionItems.length === 0 ? ( -
- No current build/buy exception items from linked sales-order planning. -
- ) : ( -
- {materialExceptionItems.map((item) => ( -
-
-
-
{item.itemSku}
-
{item.itemName}
-
-
- Build {item.recommendedBuildQuantity} · Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity} -
-
-
- ))} -
- )} + {materialExceptionItems.length === 0 ?
No current build/buy exception items from linked sales-order planning.
:
{materialExceptionItems.map((item) => (
{item.itemSku}
{item.itemName}
Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}
))}
}
+
+
+

Linked Purchasing

Purchase orders and receipts tied back to the project sales order.

{project.salesOrderId ? Open purchasing : null}
+ {project.cockpit.purchasing.purchaseOrders.length === 0 ?
No linked purchase orders are tied to this project yet.
:
{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (
{purchaseOrder.documentNumber}
{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}
${purchaseOrder.linkedLineValue.toFixed(2)} linked value
{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received
))}
} +
+
+

Readiness Drivers

+
+
Risk posture
{project.cockpit.risk.riskLevel}
{project.cockpit.risk.outstandingPurchaseOrderCount} PO(s) still waiting on receipts.
+
Blocked milestones: {project.cockpit.risk.blockedMilestoneCount}
+
Overdue execution items: {project.cockpit.risk.overdueMilestoneCount + project.cockpit.risk.overdueWorkOrderCount}
+
Uncovered material quantity: {project.cockpit.risk.totalUncoveredQuantity}
+
+
+
+
+
+

Vendor Exposure

+ {project.cockpit.purchasing.vendors.length === 0 ?
No supplier exposure exists until purchasing is linked.
:
{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (
{vendor.vendorName}
{vendor.orderCount} linked order(s)
${vendor.linkedLineValue.toFixed(2)}
{vendor.outstandingQuantity} outstanding qty
))}
} +
+
+

Recent Receipts

+ {project.cockpit.purchasing.recentReceipts.length === 0 ?
No purchase receipts have been posted against linked project supply.
:
{project.cockpit.purchasing.recentReceipts.map((receipt) => (
{receipt.receiptNumber}
{receipt.vendorName} - {receipt.purchaseOrderNumber}
{new Date(receipt.receivedAt).toLocaleDateString()}
{receipt.totalQuantity} units received
))}
} +
+

Customer Linkage

@@ -259,143 +215,43 @@ export function ProjectDetailPage() {

Commercial + Delivery Links

-
-
Quote
-
{project.salesQuoteNumber ? {project.salesQuoteNumber} : "Not linked"}
-
-
-
Sales Order
-
{project.salesOrderNumber ? {project.salesOrderNumber} : "Not linked"}
-
-
-
Shipment
-
{project.shipmentNumber ? {project.shipmentNumber} : "Not linked"}
-
+
Quote
{project.salesQuoteNumber ? {project.salesQuoteNumber} : "Not linked"}
+
Sales Order
{project.salesOrderNumber ? {project.salesOrderNumber} : "Not linked"}
+
Shipment
{project.shipmentNumber ? {project.shipmentNumber} : "Not linked"}
-
-

Milestones

-

Track project checkpoints, blockers, and completion progress.

-
- {canManage ? ( - - Edit milestones - - ) : null} +

Milestones

Track project checkpoints, blockers, and completion progress.

+ {canManage ? Edit milestones : null}
- {project.milestones.length === 0 ? ( -
- No milestones are defined for this project yet. -
- ) : ( -
- {project.milestones.map((milestone) => ( -
-
-
-
{milestone.title}
-
- - {milestone.status.replace("_", " ")} - - - Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"} - - {milestone.completedAt ? ( - Completed {new Date(milestone.completedAt).toLocaleDateString()} - ) : null} -
- {milestone.notes ?
{milestone.notes}
: null} -
-
-
- ))} -
- )} + {project.milestones.length === 0 ?
No milestones are defined for this project yet.
:
{project.milestones.map((milestone) => (
{milestone.title}
{milestone.status.replace("_", " ")}Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}{milestone.completedAt ? Completed {new Date(milestone.completedAt).toLocaleDateString()} : null}
{milestone.notes ?
{milestone.notes}
: null}
))}
}
{planning ? (

Material Readiness

-
-

Build Qty

-
{planning.summary.totalBuildQuantity}
-
-
-

Buy Qty

-
{planning.summary.totalPurchaseQuantity}
-
-
-

Uncovered Qty

-
{planning.summary.totalUncoveredQuantity}
-
-
-

Shortage Items

-
{planning.summary.uncoveredItemCount}
-
+

Build Qty

{planning.summary.totalBuildQuantity}
+

Buy Qty

{planning.summary.totalPurchaseQuantity}
+

Uncovered Qty

{planning.summary.totalUncoveredQuantity}
+

Shortage Items

{planning.summary.uncoveredItemCount}
- {planning.items - .filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0) - .slice(0, 8) - .map((item) => ( -
-
-
-
{item.itemSku}
-
{item.itemName}
-
-
- Build {item.recommendedBuildQuantity} · Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity} -
-
-
- ))} + {planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => ( +
{item.itemSku}
{item.itemName}
Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}
+ ))}
) : null}
-
-

Manufacturing Links

-

Work orders already linked to this project.

-
- {canManage ? ( - - New work order - - ) : null} +

Manufacturing Links

Work orders already linked to this project.

+ {canManage ? New work order : null}
- {workOrders.length === 0 ? ( -
No work orders are linked to this project yet.
- ) : ( -
- {workOrders.map((workOrder) => ( - -
-
-
{workOrder.workOrderNumber}
-
{workOrder.itemSku} · {workOrder.completedQuantity}/{workOrder.quantity} complete
-
-
{workOrder.status.replace("_", " ")}
-
- - ))} -
- )} + {workOrders.length === 0 ?
No work orders are linked to this project yet.
:
{workOrders.map((workOrder) => (
{workOrder.workOrderNumber}
{workOrder.itemSku} - {workOrder.completedQuantity}/{workOrder.quantity} complete
{workOrder.status.replace("_", " ")}
))}
}
- +
{status}
); } - diff --git a/server/src/modules/projects/service.ts b/server/src/modules/projects/service.ts index 244d816..426d7aa 100644 --- a/server/src/modules/projects/service.ts +++ b/server/src/modules/projects/service.ts @@ -1,4 +1,9 @@ import type { + ProjectCockpitDto, + ProjectCockpitPurchaseOrderDto, + ProjectCockpitReceiptDto, + ProjectCockpitRiskLevel, + ProjectCockpitVendorDto, ProjectCustomerOptionDto, ProjectDetailDto, ProjectDocumentOptionDto, @@ -13,9 +18,11 @@ import type { ProjectSummaryDto, WorkOrderStatus, } from "@mrp/shared"; +import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js"; import { logAuditEvent } from "../../lib/audit.js"; import { prisma } from "../../lib/prisma.js"; +import { getSalesOrderPlanningById } from "../sales/planning.js"; const projectModel = (prisma as any).project; @@ -70,6 +77,76 @@ type ProjectRecord = { }>; }; +type ProjectSalesDocumentRecord = { + documentNumber: string; + status: string; + discountPercent: number; + taxPercent: number; + freightAmount: number; + lines: Array<{ + quantity: number; + unitPrice: number; + }>; +}; + +type ProjectShipmentRecord = { + shipmentNumber: string; + status: string; + shipDate: Date | null; + packageCount: number; + trackingNumber: string; + carrier: string; + serviceLevel: string; +}; + +type ProjectPurchaseOrderRecord = { + id: string; + documentNumber: string; + status: string; + issueDate: Date; + vendor: { + id: string; + name: string; + }; + lines: Array<{ + id: string; + quantity: number; + unitCost: number; + receiptLines: Array<{ + quantity: number; + }>; + }>; +}; + +type ProjectReceiptLineRecord = { + quantity: number; + purchaseReceipt: { + id: string; + receiptNumber: string; + receivedAt: Date; + purchaseOrder: { + id: string; + documentNumber: string; + vendor: { + name: string; + }; + }; + }; +}; + +function roundMoney(value: number) { + return Math.round(value * 100) / 100; +} + +function calculateSalesDocumentTotal(record: ProjectSalesDocumentRecord) { + const subtotal = roundMoney(record.lines.reduce((sum, line) => sum + (line.quantity * line.unitPrice), 0)); + const discountAmount = roundMoney(subtotal * (record.discountPercent / 100)); + const taxableSubtotal = roundMoney(subtotal - discountAmount); + const taxAmount = roundMoney(taxableSubtotal * (record.taxPercent / 100)); + + return roundMoney(taxableSubtotal + taxAmount + roundMoney(record.freightAmount)); +} + function getOwnerName(owner: ProjectRecord["owner"]) { return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null; } @@ -114,6 +191,297 @@ function buildProjectRollups(record: ProjectRecord): ProjectRollupDto { }; } +function deriveProjectRiskLevel(score: number): ProjectCockpitRiskLevel { + if (score >= 85) { + return "LOW"; + } + if (score >= 65) { + return "MEDIUM"; + } + return "HIGH"; +} + +async function buildProjectCockpit(record: ProjectRecord, rollups: ProjectRollupDto): Promise { + const blockedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "BLOCKED").length; + + const [quote, salesOrder, shipment, purchaseOrders, receiptLines, planning] = await Promise.all([ + record.salesQuote + ? prisma.salesQuote.findUnique({ + where: { id: record.salesQuote.id }, + select: { + documentNumber: true, + status: true, + discountPercent: true, + taxPercent: true, + freightAmount: true, + lines: { + select: { + quantity: true, + unitPrice: true, + }, + }, + }, + }) + : Promise.resolve(null), + record.salesOrder + ? prisma.salesOrder.findUnique({ + where: { id: record.salesOrder.id }, + select: { + documentNumber: true, + status: true, + discountPercent: true, + taxPercent: true, + freightAmount: true, + lines: { + select: { + quantity: true, + unitPrice: true, + }, + }, + }, + }) + : Promise.resolve(null), + record.shipment + ? prisma.shipment.findUnique({ + where: { id: record.shipment.id }, + select: { + shipmentNumber: true, + status: true, + shipDate: true, + packageCount: true, + trackingNumber: true, + carrier: true, + serviceLevel: true, + }, + }) + : Promise.resolve(null), + record.salesOrder + ? prisma.purchaseOrder.findMany({ + where: { + lines: { + some: { + salesOrderId: record.salesOrder.id, + }, + }, + }, + select: { + id: true, + documentNumber: true, + status: true, + issueDate: true, + vendor: { + select: { + id: true, + name: true, + }, + }, + lines: { + where: { + salesOrderId: record.salesOrder.id, + }, + select: { + id: true, + quantity: true, + unitCost: true, + receiptLines: { + select: { + quantity: true, + }, + }, + }, + }, + }, + orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }], + }) + : Promise.resolve([]), + record.salesOrder + ? prisma.purchaseReceiptLine.findMany({ + where: { + purchaseOrderLine: { + salesOrderId: record.salesOrder.id, + }, + }, + select: { + quantity: true, + purchaseReceipt: { + select: { + id: true, + receiptNumber: true, + receivedAt: true, + purchaseOrder: { + select: { + id: true, + documentNumber: true, + vendor: { + select: { + name: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: [{ purchaseReceipt: { receivedAt: "desc" } }, { createdAt: "desc" }], + }) + : Promise.resolve([]), + record.salesOrder ? getSalesOrderPlanningById(record.salesOrder.id) : Promise.resolve(null), + ]); + + const typedPurchaseOrders = purchaseOrders as ProjectPurchaseOrderRecord[]; + const typedReceiptLines = receiptLines as ProjectReceiptLineRecord[]; + const typedQuote = quote as ProjectSalesDocumentRecord | null; + const typedSalesOrder = salesOrder as ProjectSalesDocumentRecord | null; + const typedShipment = shipment as ProjectShipmentRecord | null; + const typedPlanning = planning as SalesOrderPlanningDto | null; + + const purchaseOrdersSummary: ProjectCockpitPurchaseOrderDto[] = typedPurchaseOrders.map((purchaseOrder) => { + const linkedLineCount = purchaseOrder.lines.length; + const linkedLineValue = roundMoney( + purchaseOrder.lines.reduce((sum, line) => sum + (line.quantity * line.unitCost), 0) + ); + const totalOrderedQuantity = purchaseOrder.lines.reduce((sum, line) => sum + line.quantity, 0); + const totalReceivedQuantity = purchaseOrder.lines.reduce( + (sum, line) => sum + line.receiptLines.reduce((lineSum, receiptLine) => lineSum + receiptLine.quantity, 0), + 0 + ); + const totalOutstandingQuantity = Math.max(0, totalOrderedQuantity - totalReceivedQuantity); + + return { + id: purchaseOrder.id, + documentNumber: purchaseOrder.documentNumber, + vendorId: purchaseOrder.vendor.id, + vendorName: purchaseOrder.vendor.name, + status: purchaseOrder.status, + issueDate: purchaseOrder.issueDate.toISOString(), + linkedLineCount, + linkedLineValue, + totalOrderedQuantity, + totalReceivedQuantity, + totalOutstandingQuantity, + receiptCount: purchaseOrder.lines.reduce((sum, line) => sum + line.receiptLines.length, 0), + }; + }); + + const vendorMap = new Map(); + for (const purchaseOrder of purchaseOrdersSummary) { + const existing = vendorMap.get(purchaseOrder.vendorId); + if (existing) { + existing.orderCount += 1; + existing.linkedLineValue = roundMoney(existing.linkedLineValue + purchaseOrder.linkedLineValue); + existing.outstandingQuantity += purchaseOrder.totalOutstandingQuantity; + continue; + } + + vendorMap.set(purchaseOrder.vendorId, { + vendorId: purchaseOrder.vendorId, + vendorName: purchaseOrder.vendorName, + orderCount: 1, + linkedLineValue: purchaseOrder.linkedLineValue, + outstandingQuantity: purchaseOrder.totalOutstandingQuantity, + }); + } + + const receiptMap = new Map(); + for (const receiptLine of typedReceiptLines) { + const receipt = receiptLine.purchaseReceipt; + const existing = receiptMap.get(receipt.id); + if (existing) { + existing.totalQuantity += receiptLine.quantity; + continue; + } + + receiptMap.set(receipt.id, { + receiptId: receipt.id, + receiptNumber: receipt.receiptNumber, + purchaseOrderId: receipt.purchaseOrder.id, + purchaseOrderNumber: receipt.purchaseOrder.documentNumber, + vendorName: receipt.purchaseOrder.vendor.name, + receivedAt: receipt.receivedAt.toISOString(), + totalQuantity: receiptLine.quantity, + }); + } + + const totalOrderedQuantity = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.totalOrderedQuantity, 0); + const totalReceivedQuantity = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.totalReceivedQuantity, 0); + const totalOutstandingQuantity = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.totalOutstandingQuantity, 0); + const linkedLineCount = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.linkedLineCount, 0); + const linkedLineValue = roundMoney( + purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.linkedLineValue, 0) + ); + const outstandingPurchaseOrderCount = purchaseOrdersSummary.filter((purchaseOrder) => purchaseOrder.totalOutstandingQuantity > 0).length; + const shortageItemCount = typedPlanning?.summary.uncoveredItemCount ?? 0; + const totalUncoveredQuantity = typedPlanning?.summary.totalUncoveredQuantity ?? 0; + const purchaseOutstandingPenalty = totalOrderedQuantity > 0 + ? Math.min(12, Math.round((totalOutstandingQuantity / totalOrderedQuantity) * 12)) + : 0; + const readinessScore = Math.max( + 0, + 100 + - Math.min(20, blockedMilestoneCount * 10) + - Math.min(18, rollups.overdueMilestoneCount * 9) + - Math.min(18, rollups.overdueWorkOrderCount * 9) + - Math.min(24, shortageItemCount * 8) + - purchaseOutstandingPenalty + ); + + const commercialQuoteTotal = typedQuote ? calculateSalesDocumentTotal(typedQuote) : null; + const commercialOrderTotal = typedSalesOrder ? calculateSalesDocumentTotal(typedSalesOrder) : null; + + return { + commercial: { + quoteTotal: commercialQuoteTotal, + quoteStatus: typedQuote?.status ?? null, + orderTotal: commercialOrderTotal, + orderStatus: typedSalesOrder?.status ?? null, + activeDocumentType: typedSalesOrder ? "ORDER" : typedQuote ? "QUOTE" : null, + activeDocumentNumber: typedSalesOrder?.documentNumber ?? typedQuote?.documentNumber ?? null, + activeDocumentStatus: typedSalesOrder?.status ?? typedQuote?.status ?? null, + activeDocumentTotal: commercialOrderTotal ?? commercialQuoteTotal, + }, + purchasing: { + linkedPurchaseOrderCount: purchaseOrdersSummary.length, + openPurchaseOrderCount: purchaseOrdersSummary.filter((purchaseOrder) => purchaseOrder.status !== "CLOSED").length, + vendorCount: vendorMap.size, + linkedLineCount, + linkedLineValue, + totalOrderedQuantity, + totalReceivedQuantity, + totalOutstandingQuantity, + purchaseOrders: purchaseOrdersSummary, + vendors: [...vendorMap.values()].sort((left, right) => { + if (right.outstandingQuantity !== left.outstandingQuantity) { + return right.outstandingQuantity - left.outstandingQuantity; + } + return right.linkedLineValue - left.linkedLineValue; + }), + recentReceipts: [...receiptMap.values()] + .sort((left, right) => new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime()) + .slice(0, 5), + }, + delivery: { + shipmentNumber: typedShipment?.shipmentNumber ?? null, + shipmentStatus: typedShipment?.status ?? null, + shipDate: typedShipment?.shipDate ? typedShipment.shipDate.toISOString() : null, + packageCount: typedShipment?.packageCount ?? 0, + trackingNumber: typedShipment?.trackingNumber ?? null, + carrier: typedShipment?.carrier ?? null, + serviceLevel: typedShipment?.serviceLevel ?? null, + }, + risk: { + readinessScore, + riskLevel: deriveProjectRiskLevel(readinessScore), + blockedMilestoneCount, + overdueMilestoneCount: rollups.overdueMilestoneCount, + overdueWorkOrderCount: rollups.overdueWorkOrderCount, + shortageItemCount, + totalUncoveredQuantity, + outstandingPurchaseOrderCount, + outstandingReceiptQuantity: totalOutstandingQuantity, + }, + }; +} + function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto { return { id: record.id, @@ -131,7 +499,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto { }; } -function mapProjectDetail(record: ProjectRecord): ProjectDetailDto { +function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): ProjectDetailDto { return { ...mapProjectSummary(record), notes: record.notes, @@ -145,6 +513,7 @@ function mapProjectDetail(record: ProjectRecord): ProjectDetailDto { customerEmail: record.customer.email, customerPhone: record.customer.phone, milestones: record.milestones.map(mapProjectMilestone), + cockpit, }; } @@ -511,7 +880,13 @@ export async function getProjectById(projectId: string) { include: buildInclude(), }); - return project ? mapProjectDetail(project as ProjectRecord) : null; + if (!project) { + return null; + } + + const mappedProject = project as ProjectRecord; + const cockpit = await buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject)); + return mapProjectDetail(mappedProject, cockpit); } export async function createProject(payload: ProjectInput, actorId?: string | null) { diff --git a/shared/src/projects/types.ts b/shared/src/projects/types.ts index bcb6d95..766c817 100644 --- a/shared/src/projects/types.ts +++ b/shared/src/projects/types.ts @@ -1,10 +1,12 @@ export const projectStatuses = ["PLANNED", "ACTIVE", "ON_HOLD", "AT_RISK", "COMPLETE"] as const; export const projectPriorities = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] as const; export const projectMilestoneStatuses = ["PLANNED", "IN_PROGRESS", "BLOCKED", "COMPLETE"] as const; +export const projectCockpitRiskLevels = ["LOW", "MEDIUM", "HIGH"] as const; export type ProjectStatus = (typeof projectStatuses)[number]; export type ProjectPriority = (typeof projectPriorities)[number]; export type ProjectMilestoneStatus = (typeof projectMilestoneStatuses)[number]; +export type ProjectCockpitRiskLevel = (typeof projectCockpitRiskLevels)[number]; export interface ProjectCustomerOptionDto { id: string; @@ -69,6 +71,93 @@ export interface ProjectMilestoneDto { sortOrder: number; } +export interface ProjectCockpitCommercialDto { + quoteTotal: number | null; + quoteStatus: string | null; + orderTotal: number | null; + orderStatus: string | null; + activeDocumentType: "QUOTE" | "ORDER" | null; + activeDocumentNumber: string | null; + activeDocumentStatus: string | null; + activeDocumentTotal: number | null; +} + +export interface ProjectCockpitPurchaseOrderDto { + id: string; + documentNumber: string; + vendorId: string; + vendorName: string; + status: string; + issueDate: string; + linkedLineCount: number; + linkedLineValue: number; + totalOrderedQuantity: number; + totalReceivedQuantity: number; + totalOutstandingQuantity: number; + receiptCount: number; +} + +export interface ProjectCockpitVendorDto { + vendorId: string; + vendorName: string; + orderCount: number; + linkedLineValue: number; + outstandingQuantity: number; +} + +export interface ProjectCockpitReceiptDto { + receiptId: string; + receiptNumber: string; + purchaseOrderId: string; + purchaseOrderNumber: string; + vendorName: string; + receivedAt: string; + totalQuantity: number; +} + +export interface ProjectCockpitPurchasingDto { + linkedPurchaseOrderCount: number; + openPurchaseOrderCount: number; + vendorCount: number; + linkedLineCount: number; + linkedLineValue: number; + totalOrderedQuantity: number; + totalReceivedQuantity: number; + totalOutstandingQuantity: number; + purchaseOrders: ProjectCockpitPurchaseOrderDto[]; + vendors: ProjectCockpitVendorDto[]; + recentReceipts: ProjectCockpitReceiptDto[]; +} + +export interface ProjectCockpitDeliveryDto { + shipmentNumber: string | null; + shipmentStatus: string | null; + shipDate: string | null; + packageCount: number; + trackingNumber: string | null; + carrier: string | null; + serviceLevel: string | null; +} + +export interface ProjectCockpitRiskDto { + readinessScore: number; + riskLevel: ProjectCockpitRiskLevel; + blockedMilestoneCount: number; + overdueMilestoneCount: number; + overdueWorkOrderCount: number; + shortageItemCount: number; + totalUncoveredQuantity: number; + outstandingPurchaseOrderCount: number; + outstandingReceiptQuantity: number; +} + +export interface ProjectCockpitDto { + commercial: ProjectCockpitCommercialDto; + purchasing: ProjectCockpitPurchasingDto; + delivery: ProjectCockpitDeliveryDto; + risk: ProjectCockpitRiskDto; +} + export interface ProjectMilestoneInput { id?: string | null; title: string; @@ -90,6 +179,7 @@ export interface ProjectDetailDto extends ProjectSummaryDto { customerEmail: string; customerPhone: string; milestones: ProjectMilestoneDto[]; + cockpit: ProjectCockpitDto; } export interface ProjectInput {