projects milestones

This commit is contained in:
2026-03-17 07:34:08 -05:00
parent c3f0adc676
commit c1f6386e7d
13 changed files with 510 additions and 46 deletions

View File

@@ -0,0 +1,16 @@
CREATE TABLE "ProjectMilestone" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL,
"dueDate" DATETIME,
"completedAt" DATETIME,
"notes" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ProjectMilestone_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "ProjectMilestone_projectId_sortOrder_idx" ON "ProjectMilestone"("projectId", "sortOrder");
CREATE INDEX "ProjectMilestone_projectId_dueDate_idx" ON "ProjectMilestone"("projectId", "dueDate");

View File

@@ -583,12 +583,30 @@ model Project {
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
workOrders WorkOrder[]
milestones ProjectMilestone[]
@@index([customerId, createdAt])
@@index([ownerId, dueDate])
@@index([status, priority])
}
model ProjectMilestone {
id String @id @default(cuid())
projectId String
title String
status String
dueDate DateTime?
completedAt DateTime?
notes String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, sortOrder])
@@index([projectId, dueDate])
}
model WorkOrder {
id String @id @default(cuid())
workOrderNumber String @unique

View File

@@ -1,4 +1,4 @@
import { permissions, projectPriorities, projectStatuses } from "@mrp/shared";
import { permissions, projectMilestoneStatuses, projectPriorities, projectStatuses } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
@@ -27,6 +27,16 @@ const projectSchema = z.object({
ownerId: z.string().trim().min(1).nullable(),
dueDate: z.string().datetime().nullable(),
notes: z.string(),
milestones: z.array(
z.object({
id: z.string().trim().min(1).nullable().optional(),
title: z.string().trim().min(1).max(160),
status: z.enum(projectMilestoneStatuses),
dueDate: z.string().datetime().nullable(),
notes: z.string(),
sortOrder: z.number().int(),
})
),
});
const projectListQuerySchema = z.object({

View File

@@ -3,11 +3,15 @@ import type {
ProjectDetailDto,
ProjectDocumentOptionDto,
ProjectInput,
ProjectMilestoneDto,
ProjectMilestoneInput,
ProjectOwnerOptionDto,
ProjectPriority,
ProjectRollupDto,
ProjectShipmentOptionDto,
ProjectStatus,
ProjectSummaryDto,
WorkOrderStatus,
} from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js";
@@ -15,6 +19,16 @@ import { prisma } from "../../lib/prisma.js";
const projectModel = (prisma as any).project;
type ProjectMilestoneRecord = {
id: string;
title: string;
status: string;
dueDate: Date | null;
completedAt: Date | null;
notes: string;
sortOrder: number;
};
type ProjectRecord = {
id: string;
projectNumber: string;
@@ -48,12 +62,58 @@ type ProjectRecord = {
id: string;
shipmentNumber: string;
} | null;
milestones: ProjectMilestoneRecord[];
workOrders: Array<{
id: string;
status: string;
dueDate: Date | null;
}>;
};
function getOwnerName(owner: ProjectRecord["owner"]) {
return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null;
}
function mapProjectMilestone(record: ProjectMilestoneRecord): ProjectMilestoneDto {
return {
id: record.id,
title: record.title,
status: record.status as ProjectMilestoneDto["status"],
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
completedAt: record.completedAt ? record.completedAt.toISOString() : null,
notes: record.notes,
sortOrder: record.sortOrder,
};
}
function buildProjectRollups(record: ProjectRecord): ProjectRollupDto {
const now = Date.now();
const milestoneCount = record.milestones.length;
const completedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "COMPLETE").length;
const overdueMilestoneCount = record.milestones.filter(
(milestone) => milestone.status !== "COMPLETE" && milestone.dueDate && milestone.dueDate.getTime() < now
).length;
const workOrderCount = record.workOrders.length;
const completedWorkOrderCount = record.workOrders.filter((workOrder) => workOrder.status === "COMPLETE").length;
const activeStatuses = new Set<WorkOrderStatus>(["RELEASED", "IN_PROGRESS", "ON_HOLD"]);
const closedStatuses = new Set<WorkOrderStatus>(["COMPLETE", "CANCELLED"]);
const activeWorkOrderCount = record.workOrders.filter((workOrder) => activeStatuses.has(workOrder.status as WorkOrderStatus)).length;
const overdueWorkOrderCount = record.workOrders.filter(
(workOrder) => !closedStatuses.has(workOrder.status as WorkOrderStatus) && workOrder.dueDate && workOrder.dueDate.getTime() < now
).length;
return {
milestoneCount,
completedMilestoneCount,
openMilestoneCount: milestoneCount - completedMilestoneCount,
overdueMilestoneCount,
workOrderCount,
activeWorkOrderCount,
completedWorkOrderCount,
overdueWorkOrderCount,
};
}
function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
return {
id: record.id,
@@ -67,6 +127,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
ownerName: getOwnerName(record.owner),
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
updatedAt: record.updatedAt.toISOString(),
rollups: buildProjectRollups(record),
};
}
@@ -83,6 +144,7 @@ function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
shipmentNumber: record.shipment?.shipmentNumber ?? null,
customerEmail: record.customer.email,
customerPhone: record.customer.phone,
milestones: record.milestones.map(mapProjectMilestone),
};
}
@@ -121,6 +183,25 @@ function buildInclude() {
shipmentNumber: true,
},
},
milestones: {
select: {
id: true,
title: true,
status: true,
dueDate: true,
completedAt: true,
notes: true,
sortOrder: true,
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
workOrders: {
select: {
id: true,
status: true,
dueDate: true,
},
},
};
}
@@ -204,6 +285,82 @@ async function validateProjectInput(payload: ProjectInput) {
return { ok: true as const };
}
function normalizeMilestoneInput(milestone: ProjectMilestoneInput, index: number) {
const title = milestone.title.trim();
const notes = milestone.notes.trim();
const sortOrder = Number.isFinite(milestone.sortOrder) ? milestone.sortOrder : index * 10;
return {
id: milestone.id?.trim() || null,
title,
status: milestone.status,
dueDate: milestone.dueDate ? new Date(milestone.dueDate) : null,
notes,
sortOrder,
};
}
async function syncProjectMilestones(transaction: any, projectId: string, milestones: ProjectMilestoneInput[]) {
const milestoneModel = (transaction as any).projectMilestone;
const existingMilestones = await milestoneModel.findMany({
where: { projectId },
select: {
id: true,
completedAt: true,
},
});
const existingById = new Map<string, { completedAt: Date | null }>(
existingMilestones.map((milestone: { id: string; completedAt: Date | null }) => [milestone.id, { completedAt: milestone.completedAt }])
);
const normalized = milestones.map(normalizeMilestoneInput);
const retainedIds = normalized.flatMap((milestone) => (milestone.id && existingById.has(milestone.id) ? [milestone.id] : []));
await milestoneModel.deleteMany({
where: {
projectId,
...(retainedIds.length > 0 ? { id: { notIn: retainedIds } } : {}),
},
});
if (retainedIds.length === 0) {
await milestoneModel.deleteMany({ where: { projectId } });
}
for (const milestone of normalized) {
const existing = milestone.id ? existingById.get(milestone.id) : null;
const completedAt = milestone.status === "COMPLETE" ? existing?.completedAt ?? new Date() : null;
if (milestone.id && existing) {
await milestoneModel.update({
where: { id: milestone.id },
data: {
title: milestone.title,
status: milestone.status,
dueDate: milestone.dueDate,
completedAt,
notes: milestone.notes,
sortOrder: milestone.sortOrder,
},
});
continue;
}
await milestoneModel.create({
data: {
projectId,
title: milestone.title,
status: milestone.status,
dueDate: milestone.dueDate,
completedAt,
notes: milestone.notes,
sortOrder: milestone.sortOrder,
},
});
}
}
export async function listProjectCustomerOptions(): Promise<ProjectCustomerOptionDto[]> {
const customers = await prisma.customer.findMany({
where: {
@@ -364,23 +521,29 @@ export async function createProject(payload: ProjectInput, actorId?: string | nu
}
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 created = await prisma.$transaction(async (transaction) => {
const transactionProjectModel = (transaction as any).project;
const createdProject = await transactionProjectModel.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,
},
});
await syncProjectMilestones(transaction, createdProject.id, payload.milestones ?? []);
return createdProject;
});
const project = await getProjectById(created.id);
@@ -396,6 +559,7 @@ export async function createProject(payload: ProjectInput, actorId?: string | nu
customerId: project.customerId,
status: project.status,
priority: project.priority,
milestoneCount: project.rollups.milestoneCount,
},
});
}
@@ -417,23 +581,28 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
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,
},
await prisma.$transaction(async (transaction) => {
const transactionProjectModel = (transaction as any).project;
await transactionProjectModel.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,
},
});
await syncProjectMilestones(transaction, projectId, payload.milestones ?? []);
});
const project = await getProjectById(projectId);
@@ -449,6 +618,7 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
customerId: project.customerId,
status: project.status,
priority: project.priority,
milestoneCount: project.rollups.milestoneCount,
},
});
}