projects
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { permissions } 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";
|
||||
@@ -18,6 +20,9 @@ export function ProjectDetailPage() {
|
||||
const [project, setProject] = useState<ProjectDetailDto | null>(null);
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
||||
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||
const [quote, setQuote] = useState<SalesDocumentDetailDto | null>(null);
|
||||
const [salesOrder, setSalesOrder] = useState<SalesDocumentDetailDto | null>(null);
|
||||
const [shipment, setShipment] = useState<ShipmentDetailDto | null>(null);
|
||||
const [status, setStatus] = useState("Loading project...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
||||
@@ -28,17 +33,23 @@ export function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
api.getProject(token, projectId)
|
||||
.then((nextProject) => {
|
||||
.then(async (nextProject) => {
|
||||
setProject(nextProject);
|
||||
setStatus("Project loaded.");
|
||||
if (nextProject.salesOrderId) {
|
||||
api.getSalesOrderPlanning(token, nextProject.salesOrderId).then(setPlanning).catch(() => setPlanning(null));
|
||||
} else {
|
||||
setPlanning(null);
|
||||
}
|
||||
return api.getWorkOrders(token, { projectId: nextProject.id });
|
||||
const [nextPlanning, nextWorkOrders, nextQuote, nextSalesOrder, nextShipment] = 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);
|
||||
})
|
||||
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to load project.";
|
||||
setStatus(message);
|
||||
@@ -49,6 +60,48 @@ export function ProjectDetailPage() {
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
const sortedMilestones = [...project.milestones].sort((left, right) => {
|
||||
if (left.status === "COMPLETE" && right.status !== "COMPLETE") {
|
||||
return 1;
|
||||
}
|
||||
if (left.status !== "COMPLETE" && right.status === "COMPLETE") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (left.dueDate && right.dueDate) {
|
||||
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
||||
}
|
||||
if (left.dueDate) {
|
||||
return -1;
|
||||
}
|
||||
if (right.dueDate) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
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 nextWorkOrder = [...activeWorkOrders]
|
||||
.sort((left, right) => {
|
||||
if (left.dueDate && right.dueDate) {
|
||||
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
||||
}
|
||||
if (left.dueDate) {
|
||||
return -1;
|
||||
}
|
||||
if (right.dueDate) {
|
||||
return 1;
|
||||
}
|
||||
return left.workOrderNumber.localeCompare(right.workOrderNumber);
|
||||
})[0] ?? null;
|
||||
const materialExceptionItems = planning
|
||||
? planning.items.filter((item) => item.uncoveredQuantity > 0 || item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0).slice(0, 5)
|
||||
: [];
|
||||
const completionPercent = project.rollups.milestoneCount > 0
|
||||
? Math.round((project.rollups.completedMilestoneCount / project.rollups.milestoneCount) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
@@ -95,6 +148,100 @@ export function ProjectDetailPage() {
|
||||
<div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project Cockpit</p>
|
||||
<h4 className="mt-2 text-lg font-bold text-text">Cross-functional execution view</h4>
|
||||
<p className="mt-2 text-sm text-muted">Commercial, supply, execution, and delivery signals for this program in one place.</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-right">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone Progress</div>
|
||||
<div className="mt-1 text-2xl font-bold text-text">{completionPercent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial</p>
|
||||
<div className="mt-2 text-base font-bold text-text">
|
||||
{salesOrder ? `$${salesOrder.total.toFixed(2)}` : quote ? `$${quote.total.toFixed(2)}` : "Not linked"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{salesOrder ? `${salesOrder.documentNumber} · ${salesOrder.status}` : quote ? `${quote.documentNumber} · ${quote.status}` : "Link a quote or sales order"}
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Supply</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{planning ? planning.summary.uncoveredItemCount : 0} shortage items</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{planning ? `Build ${planning.summary.totalBuildQuantity} · Buy ${planning.summary.totalPurchaseQuantity}` : "No sales-order planning linked"}
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Execution</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{project.rollups.activeWorkOrderCount} active work orders</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{nextWorkOrder ? `${nextWorkOrder.workOrderNumber} due ${nextWorkOrder.dueDate ? new Date(nextWorkOrder.dueDate).toLocaleDateString() : "unscheduled"}` : "No active work order due date"}
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Delivery</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{shipment ? shipment.status.replace("_", " ") : "Not linked"}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{shipment ? `${shipment.shipmentNumber} · ${shipment.packageCount} package(s)` : "Link a shipment to track delivery"}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Next Checkpoints</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone</div>
|
||||
<div className="mt-2 font-semibold text-text">{nextMilestone ? nextMilestone.title : "All milestones complete"}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{nextMilestone
|
||||
? `${nextMilestone.status.replace("_", " ")} · ${nextMilestone.dueDate ? new Date(nextMilestone.dueDate).toLocaleDateString() : "No due date"}`
|
||||
: "No open milestone remains."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Work Order</div>
|
||||
<div className="mt-2 font-semibold text-text">{nextWorkOrder ? nextWorkOrder.workOrderNumber : "No active work orders"}</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{nextWorkOrder
|
||||
? `${nextWorkOrder.itemSku} · ${nextWorkOrder.completedQuantity}/${nextWorkOrder.quantity} complete`
|
||||
: "Launch or link a work order to populate execution checkpoints."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Watchlist</p>
|
||||
{materialExceptionItems.length === 0 ? (
|
||||
<div className="mt-4 rounded-[16px] border border-dashed border-line/70 bg-surface/80 px-3 py-4 text-sm text-muted">
|
||||
No current build/buy exception items from linked sales-order planning.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{materialExceptionItems.map((item) => (
|
||||
<div key={item.itemId} className="rounded-[16px] border border-line/70 bg-surface/80 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{item.itemSku}</div>
|
||||
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
Build {item.recommendedBuildQuantity} · Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p>
|
||||
|
||||
Reference in New Issue
Block a user