This commit is contained in:
2026-03-17 21:04:33 -05:00
parent cdbd54b8cc
commit c06cb66893
7 changed files with 276 additions and 6 deletions

View File

@@ -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<ProjectTimelineEntryDto[]> {
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<ProjectCockpitDto> {
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) {