From c06cb668939afb782963eb06f46f90dd0de936e3 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 17 Mar 2026 21:04:33 -0500 Subject: [PATCH] projects --- CHANGELOG.md | 2 +- README.md | 2 +- SHIPPED.md | 2 +- .../modules/projects/ProjectDetailPage.tsx | 86 +++++++++ fabdash | 1 + server/src/modules/projects/service.ts | 178 +++++++++++++++++- shared/src/projects/types.ts | 11 ++ 7 files changed, 276 insertions(+), 6 deletions(-) create mode 160000 fabdash diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f977d..194fdef 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, delivery, purchasing, readiness-risk, and project cost snapshot rollups +- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline - Project 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 02f6025..df1b7a7 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/purchasing visibility, readiness-risk scoring, a cost snapshot from linked purchasing and manufacturing data, 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, a cost snapshot from linked purchasing and manufacturing data, direct launch paths into prefilled purchasing/manufacturing follow-through, an activity timeline across linked execution records, notes, commercial document links, shipment links, attachments, and dashboard visibility. Current interactions: diff --git a/SHIPPED.md b/SHIPPED.md index 6606fc8..18c5751 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, delivery, purchasing, readiness-risk, and cost-snapshot visibility +- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline - Project list/detail/create/edit workflows and dashboard program widgets - Manufacturing foundation with work orders, project linkage, 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 3b78459..5e4f5aa 100644 --- a/client/src/modules/projects/ProjectDetailPage.tsx +++ b/client/src/modules/projects/ProjectDetailPage.tsx @@ -95,6 +95,8 @@ export function ProjectDetailPage() { const materialExceptionItems = planning ? planning.items.filter((item) => item.uncoveredQuantity > 0 || item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0).slice(0, 5) : []; + const topBuildRecommendation = planning?.items.find((item) => item.recommendedBuildQuantity > 0) ?? null; + const topPurchaseRecommendation = planning?.items.find((item) => item.recommendedPurchaseQuantity > 0) ?? null; const completionPercent = project.rollups.milestoneCount > 0 ? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100) : 0; @@ -180,6 +182,62 @@ export function ProjectDetailPage() {
+
+
+
+

Actionable Cockpit

+

Turn current exceptions into purchasing, manufacturing, and planning follow-through.

+
+ + Open gantt + +
+
+
+

Build Follow-Through

+
{topBuildRecommendation ? topBuildRecommendation.itemSku : "No build recommendation"}
+
+ {topBuildRecommendation ? `Recommended build qty ${topBuildRecommendation.recommendedBuildQuantity}` : "Planning does not currently recommend a new build."} +
+ {topBuildRecommendation && project.salesOrderId ? ( + + Launch work order + + ) : null} +
+
+

Buy Follow-Through

+
{topPurchaseRecommendation ? topPurchaseRecommendation.itemSku : "No buy recommendation"}
+
+ {topPurchaseRecommendation ? `Recommended buy qty ${topPurchaseRecommendation.recommendedPurchaseQuantity}` : "Planning does not currently recommend a new purchase."} +
+ {topPurchaseRecommendation && project.salesOrderId ? ( + + Launch purchase order + + ) : null} +
+
+
+ + New project work order + + {project.salesOrderId ? ( + + Open sales order + + ) : null} + + Review purchasing + +
+

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
))}
} @@ -256,6 +314,34 @@ export function ProjectDetailPage() { {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("_", " ")}
))}
}
+
+
+

Activity Timeline

Chronological project, milestone, purchasing, manufacturing, sales, and shipping history.

+
+ {project.timeline.length === 0 ? ( +
No timeline activity is available for this project yet.
+ ) : ( +
+ {project.timeline.map((entry) => ( +
+
+
+
{entry.sourceType}
+
+ {entry.href ? {entry.title} : entry.title} +
+
{entry.detail}
+
+
+
{new Date(entry.createdAt).toLocaleString()}
+
{entry.actorName || "System"}
+
+
+
+ ))} +
+ )} +
{status}
diff --git a/fabdash b/fabdash new file mode 160000 index 0000000..fe4d8b1 --- /dev/null +++ b/fabdash @@ -0,0 +1 @@ +Subproject commit fe4d8b120c079cd15d1d57296646f12369e17b80 diff --git a/server/src/modules/projects/service.ts b/server/src/modules/projects/service.ts index e1c0eaa..c9bbe80 100644 --- a/server/src/modules/projects/service.ts +++ b/server/src/modules/projects/service.ts @@ -4,6 +4,7 @@ import type { ProjectCockpitReceiptDto, ProjectCockpitRiskLevel, ProjectCockpitVendorDto, + ProjectTimelineEntryDto, ProjectCustomerOptionDto, ProjectDetailDto, ProjectDocumentOptionDto, @@ -156,6 +157,19 @@ type ProjectCostWorkOrderRecord = { }>; }; +type ProjectAuditEventRecord = { + id: string; + entityType: string; + entityId: string | null; + action: string; + summary: string; + createdAt: Date; + actor: { + firstName: string; + lastName: string; + } | null; +}; + function roundMoney(value: number) { return Math.round(value * 100) / 100; } @@ -223,6 +237,160 @@ function deriveProjectRiskLevel(score: number): ProjectCockpitRiskLevel { return "HIGH"; } +function getActorName(actor: { firstName: string; lastName: string } | null) { + return actor ? `${actor.firstName} ${actor.lastName}`.trim() : null; +} + +async function buildProjectTimeline(record: ProjectRecord): Promise { + const relatedEntityFilters = [ + { entityType: "project", entityId: record.id }, + ...(record.salesQuote ? [{ entityType: "sales-quote", entityId: record.salesQuote.id }] : []), + ...(record.salesOrder ? [{ entityType: "sales-order", entityId: record.salesOrder.id }] : []), + ...(record.shipment ? [{ entityType: "shipment", entityId: record.shipment.id }] : []), + ...record.workOrders.map((workOrder) => ({ entityType: "work-order", entityId: workOrder.id })), + ]; + + const [auditEvents, purchaseOrders] = await Promise.all([ + prisma.auditEvent.findMany({ + where: { + OR: relatedEntityFilters, + }, + include: { + actor: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + orderBy: [{ createdAt: "desc" }], + take: 30, + }), + record.salesOrder + ? prisma.purchaseOrder.findMany({ + where: { + lines: { + some: { + salesOrderId: record.salesOrder.id, + }, + }, + }, + select: { + id: true, + documentNumber: true, + createdAt: true, + receipts: { + select: { + id: true, + receiptNumber: true, + receivedAt: true, + createdBy: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + orderBy: [{ receivedAt: "desc" }], + }, + }, + }) + : Promise.resolve([]), + ]); + + const timeline: ProjectTimelineEntryDto[] = []; + + for (const milestone of record.milestones) { + timeline.push({ + id: `milestone-${milestone.id}-created`, + sourceType: "MILESTONE", + title: `Milestone planned: ${milestone.title}`, + detail: milestone.dueDate ? `Due ${milestone.dueDate.toLocaleDateString()}` : "No due date assigned", + createdAt: milestone.dueDate?.toISOString() ?? new Date(0).toISOString(), + actorName: null, + href: null, + }); + + if (milestone.completedAt) { + timeline.push({ + id: `milestone-${milestone.id}-completed`, + sourceType: "MILESTONE", + title: `Milestone completed: ${milestone.title}`, + detail: "Checkpoint marked complete.", + createdAt: milestone.completedAt.toISOString(), + actorName: null, + href: null, + }); + } + } + + for (const auditEvent of auditEvents as ProjectAuditEventRecord[]) { + let sourceType: ProjectTimelineEntryDto["sourceType"] = "PROJECT"; + let href: string | null = null; + if (auditEvent.entityType === "sales-quote" || auditEvent.entityType === "sales-order") { + sourceType = "SALES"; + href = auditEvent.entityType === "sales-quote" ? `/sales/quotes/${auditEvent.entityId}` : `/sales/orders/${auditEvent.entityId}`; + } else if (auditEvent.entityType === "shipment") { + sourceType = "SHIPPING"; + href = `/shipping/shipments/${auditEvent.entityId}`; + } else if (auditEvent.entityType === "work-order") { + sourceType = "MANUFACTURING"; + href = `/manufacturing/work-orders/${auditEvent.entityId}`; + } + + timeline.push({ + id: `audit-${auditEvent.id}`, + sourceType, + title: auditEvent.summary, + detail: `${auditEvent.entityType} ยท ${auditEvent.action}`.replaceAll("-", " "), + createdAt: auditEvent.createdAt.toISOString(), + actorName: getActorName(auditEvent.actor), + href, + }); + } + + for (const purchaseOrder of purchaseOrders as Array<{ + id: string; + documentNumber: string; + createdAt: Date; + receipts: Array<{ + id: string; + receiptNumber: string; + receivedAt: Date; + createdBy: { + firstName: string; + lastName: string; + } | null; + }>; + }>) { + timeline.push({ + id: `purchase-order-${purchaseOrder.id}`, + sourceType: "PURCHASING", + title: `Linked purchase order ${purchaseOrder.documentNumber}`, + detail: "Project demand is now covered by purchasing.", + createdAt: purchaseOrder.createdAt.toISOString(), + actorName: null, + href: `/purchasing/orders/${purchaseOrder.id}`, + }); + + for (const receipt of purchaseOrder.receipts) { + timeline.push({ + id: `receipt-${receipt.id}`, + sourceType: "PURCHASING", + title: `Receipt posted: ${receipt.receiptNumber}`, + detail: `Received against ${purchaseOrder.documentNumber}.`, + createdAt: receipt.receivedAt.toISOString(), + actorName: getActorName(receipt.createdBy), + href: `/purchasing/orders/${purchaseOrder.id}`, + }); + } + } + + return timeline + .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()) + .slice(0, 20); +} + async function buildProjectCockpit(record: ProjectRecord, rollups: ProjectRollupDto): Promise { const blockedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "BLOCKED").length; @@ -594,7 +762,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto { }; } -function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): ProjectDetailDto { +function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto, timeline: ProjectTimelineEntryDto[]): ProjectDetailDto { return { ...mapProjectSummary(record), notes: record.notes, @@ -609,6 +777,7 @@ function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): Pr customerPhone: record.customer.phone, milestones: record.milestones.map(mapProjectMilestone), cockpit, + timeline, }; } @@ -980,8 +1149,11 @@ export async function getProjectById(projectId: string) { } const mappedProject = project as ProjectRecord; - const cockpit = await buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject)); - return mapProjectDetail(mappedProject, cockpit); + const [cockpit, timeline] = await Promise.all([ + buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject)), + buildProjectTimeline(mappedProject), + ]); + return mapProjectDetail(mappedProject, cockpit, timeline); } export async function createProject(payload: ProjectInput, actorId?: string | null) { diff --git a/shared/src/projects/types.ts b/shared/src/projects/types.ts index 3f465c9..6a7b5a2 100644 --- a/shared/src/projects/types.ts +++ b/shared/src/projects/types.ts @@ -170,6 +170,16 @@ export interface ProjectCockpitDto { risk: ProjectCockpitRiskDto; } +export interface ProjectTimelineEntryDto { + id: string; + sourceType: "PROJECT" | "MILESTONE" | "SALES" | "PURCHASING" | "MANUFACTURING" | "SHIPPING"; + title: string; + detail: string; + createdAt: string; + actorName: string | null; + href: string | null; +} + export interface ProjectMilestoneInput { id?: string | null; title: string; @@ -192,6 +202,7 @@ export interface ProjectDetailDto extends ProjectSummaryDto { customerPhone: string; milestones: ProjectMilestoneDto[]; cockpit: ProjectCockpitDto; + timeline: ProjectTimelineEntryDto[]; } export interface ProjectInput {