projects
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Actionable Cockpit</p>
|
||||
<p className="mt-2 text-sm text-muted">Turn current exceptions into purchasing, manufacturing, and planning follow-through.</p>
|
||||
</div>
|
||||
<Link to="/planning/gantt" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Open gantt
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Follow-Through</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{topBuildRecommendation ? topBuildRecommendation.itemSku : "No build recommendation"}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{topBuildRecommendation ? `Recommended build qty ${topBuildRecommendation.recommendedBuildQuantity}` : "Planning does not currently recommend a new build."}
|
||||
</div>
|
||||
{topBuildRecommendation && project.salesOrderId ? (
|
||||
<Link
|
||||
to={`/manufacturing/work-orders/new?projectId=${project.id}&itemId=${topBuildRecommendation.itemId}&salesOrderId=${project.salesOrderId}&quantity=${topBuildRecommendation.recommendedBuildQuantity}¬es=${encodeURIComponent(`Project cockpit launch from ${project.projectNumber}`)}`}
|
||||
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Launch work order
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Follow-Through</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{topPurchaseRecommendation ? topPurchaseRecommendation.itemSku : "No buy recommendation"}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{topPurchaseRecommendation ? `Recommended buy qty ${topPurchaseRecommendation.recommendedPurchaseQuantity}` : "Planning does not currently recommend a new purchase."}
|
||||
</div>
|
||||
{topPurchaseRecommendation && project.salesOrderId ? (
|
||||
<Link
|
||||
to={`/purchasing/orders/new?planningOrderId=${project.salesOrderId}&itemId=${topPurchaseRecommendation.itemId}`}
|
||||
className="mt-4 inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Launch purchase order
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
New project work order
|
||||
</Link>
|
||||
{project.salesOrderId ? (
|
||||
<Link to={`/sales/orders/${project.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>
|
||||
) : null}
|
||||
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||
Review purchasing
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3"><div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Linked Purchasing</p><p className="mt-2 text-sm text-muted">Purchase orders and receipts tied back to the project sales order.</p></div>{project.salesOrderId ? <Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open purchasing</Link> : null}</div>
|
||||
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-6 space-y-3">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
|
||||
@@ -256,6 +314,34 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
{workOrders.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-6 space-y-3">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{workOrder.workOrderNumber}</div><div className="mt-1 text-xs text-muted">{workOrder.itemSku} - {workOrder.completedQuantity}/{workOrder.quantity} complete</div></div><div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div></div></Link>))}</div>}
|
||||
</section>
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Activity Timeline</p><p className="mt-2 text-sm text-muted">Chronological project, milestone, purchasing, manufacturing, sales, and shipping history.</p></div>
|
||||
</div>
|
||||
{project.timeline.length === 0 ? (
|
||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No timeline activity is available for this project yet.</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
{project.timeline.map((entry) => (
|
||||
<div key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
|
||||
<div className="mt-1 font-semibold text-text">
|
||||
{entry.href ? <Link to={entry.href} className="hover:text-brand">{entry.title}</Link> : entry.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted">{entry.detail}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>{new Date(entry.createdAt).toLocaleString()}</div>
|
||||
<div>{entry.actorName || "System"}</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
1
fabdash
Submodule
1
fabdash
Submodule
Submodule fabdash added at fe4d8b120c
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user