fixes
This commit is contained in:
@@ -20,6 +20,8 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
- Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface
|
- Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface
|
||||||
- Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed
|
- Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed
|
||||||
- Workbench station cards now show planned-vs-actual load so planners can compare schedule intent against recorded execution time
|
- Workbench station cards now show planned-vs-actual load so planners can compare schedule intent against recorded execution time
|
||||||
|
- Work-order `On Hold` quick status changes now require a recorded hold reason and persist the active blocker on the work-order record and audit trail
|
||||||
|
- Project milestone cards now support inline quick status actions for start, block, complete, reset, and reopen flows directly from the project detail view
|
||||||
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
|
- 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
|
- 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
|
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ Current foundation scope includes:
|
|||||||
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
||||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||||
- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments
|
- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments
|
||||||
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, attachments, reverse-linked quote/sales-order visibility, and downstream project-context carry-through into generated work orders and purchase orders
|
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, inline milestone quick-status actions, notes, attachments, reverse-linked quote/sales-order visibility, and downstream project-context carry-through into generated work orders and purchase orders
|
||||||
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, required hold reasons for `On Hold` status changes, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
||||||
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
|
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
|
||||||
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||||
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Logistics attachments directly on shipment records
|
- Logistics attachments directly on shipment records
|
||||||
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||||
- Reverse project linkage visibility on quote and sales-order detail pages, plus project-context carry-through into generated work orders and purchase orders with sales-order-driven backfill for existing records
|
- Reverse project linkage visibility on quote and sales-order detail pages, plus project-context carry-through into generated work orders and purchase orders with sales-order-driven backfill for existing records
|
||||||
- Project milestones and project-side milestone/work-order rollups
|
- Project milestones, inline milestone quick-status actions, 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, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
- 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
|
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||||
- Manufacturing foundation with work orders, project linkage, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, and work-order attachments
|
- Manufacturing foundation with work orders, project linkage, operation execution controls, operator assignment, timer-based and manual labor posting, required hold reasons for `On Hold` status changes, material issue posting, completion posting, and work-order attachments
|
||||||
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
||||||
- Vendor invoice/supporting-document attachments directly on purchase orders
|
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ interface ConfirmActionDialogProps {
|
|||||||
intent?: "danger" | "primary";
|
intent?: "danger" | "primary";
|
||||||
confirmationLabel?: string;
|
confirmationLabel?: string;
|
||||||
confirmationValue?: string;
|
confirmationValue?: string;
|
||||||
|
extraFieldLabel?: string;
|
||||||
|
extraFieldPlaceholder?: string;
|
||||||
|
extraFieldValue?: string;
|
||||||
|
extraFieldRequired?: boolean;
|
||||||
|
extraFieldMultiline?: boolean;
|
||||||
|
onExtraFieldChange?: (value: string) => void;
|
||||||
isConfirming?: boolean;
|
isConfirming?: boolean;
|
||||||
onConfirm: () => void | Promise<void>;
|
onConfirm: () => void | Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -27,6 +33,12 @@ export function ConfirmActionDialog({
|
|||||||
intent = "danger",
|
intent = "danger",
|
||||||
confirmationLabel,
|
confirmationLabel,
|
||||||
confirmationValue,
|
confirmationValue,
|
||||||
|
extraFieldLabel,
|
||||||
|
extraFieldPlaceholder,
|
||||||
|
extraFieldValue = "",
|
||||||
|
extraFieldRequired = false,
|
||||||
|
extraFieldMultiline = false,
|
||||||
|
onExtraFieldChange,
|
||||||
isConfirming = false,
|
isConfirming = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -44,7 +56,11 @@ export function ConfirmActionDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
||||||
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
|
const requiresExtraField = Boolean(extraFieldLabel);
|
||||||
|
const isConfirmDisabled =
|
||||||
|
isConfirming ||
|
||||||
|
(requiresTypedConfirmation && typedValue.trim() !== confirmationValue) ||
|
||||||
|
(requiresExtraField && extraFieldRequired && extraFieldValue.trim().length === 0);
|
||||||
const confirmButtonClass =
|
const confirmButtonClass =
|
||||||
intent === "danger"
|
intent === "danger"
|
||||||
? "bg-red-600 text-white hover:bg-red-700"
|
? "bg-red-600 text-white hover:bg-red-700"
|
||||||
@@ -81,6 +97,27 @@ export function ConfirmActionDialog({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
{requiresExtraField ? (
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">{extraFieldLabel}</span>
|
||||||
|
{extraFieldMultiline ? (
|
||||||
|
<textarea
|
||||||
|
value={extraFieldValue}
|
||||||
|
onChange={(event) => onExtraFieldChange?.(event.target.value)}
|
||||||
|
placeholder={extraFieldPlaceholder}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-[18px] border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={extraFieldValue}
|
||||||
|
onChange={(event) => onExtraFieldChange?.(event.target.value)}
|
||||||
|
placeholder={extraFieldPlaceholder}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import type {
|
|||||||
WorkOrderOperationTimerInput,
|
WorkOrderOperationTimerInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
|
WorkOrderStatusUpdateInput,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
ManufacturingUserOptionDto,
|
ManufacturingUserOptionDto,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
@@ -83,6 +84,7 @@ import type {
|
|||||||
ProjectDetailDto,
|
ProjectDetailDto,
|
||||||
ProjectDocumentOptionDto,
|
ProjectDocumentOptionDto,
|
||||||
ProjectInput,
|
ProjectInput,
|
||||||
|
ProjectMilestoneStatusUpdateInput,
|
||||||
ProjectOwnerOptionDto,
|
ProjectOwnerOptionDto,
|
||||||
ProjectPriority,
|
ProjectPriority,
|
||||||
ProjectShipmentOptionDto,
|
ProjectShipmentOptionDto,
|
||||||
@@ -601,6 +603,13 @@ export const api = {
|
|||||||
updateProject(token: string, projectId: string, payload: ProjectInput) {
|
updateProject(token: string, projectId: string, payload: ProjectInput) {
|
||||||
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
|
updateProjectMilestoneStatus(token: string, projectId: string, milestoneId: string, payload: ProjectMilestoneStatusUpdateInput) {
|
||||||
|
return request<ProjectDetailDto>(
|
||||||
|
`/api/v1/projects/${projectId}/milestones/${milestoneId}/status`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
getProjectCustomerOptions(token: string) {
|
getProjectCustomerOptions(token: string) {
|
||||||
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
|
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
|
||||||
},
|
},
|
||||||
@@ -667,10 +676,10 @@ export const api = {
|
|||||||
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
|
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
|
||||||
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
updateWorkOrderStatus(token: string, workOrderId: string, status: WorkOrderStatus) {
|
updateWorkOrderStatus(token: string, workOrderId: string, payload: WorkOrderStatusUpdateInput) {
|
||||||
return request<WorkOrderDetailDto>(
|
return request<WorkOrderDetailDto>(
|
||||||
`/api/v1/manufacturing/work-orders/${workOrderId}/status`,
|
`/api/v1/manufacturing/work-orders/${workOrderId}/status`,
|
||||||
{ method: "PATCH", body: JSON.stringify({ status }) },
|
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function WorkOrderDetailPage() {
|
|||||||
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
|
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
|
||||||
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
|
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
|
||||||
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
|
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
|
||||||
|
const [holdReasonDraft, setHoldReasonDraft] = useState("");
|
||||||
const [status, setStatus] = useState("Loading work order...");
|
const [status, setStatus] = useState("Loading work order...");
|
||||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||||
@@ -121,8 +122,12 @@ export function WorkOrderDetailPage() {
|
|||||||
setIsUpdatingStatus(true);
|
setIsUpdatingStatus(true);
|
||||||
setStatus("Updating work-order status...");
|
setStatus("Updating work-order status...");
|
||||||
try {
|
try {
|
||||||
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
|
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, {
|
||||||
|
status: nextStatus,
|
||||||
|
reason: nextStatus === "ON_HOLD" ? holdReasonDraft : null,
|
||||||
|
});
|
||||||
setWorkOrder(nextWorkOrder);
|
setWorkOrder(nextWorkOrder);
|
||||||
|
setHoldReasonDraft("");
|
||||||
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
|
||||||
@@ -332,6 +337,8 @@ export function WorkOrderDetailPage() {
|
|||||||
impact:
|
impact:
|
||||||
nextStatus === "CANCELLED"
|
nextStatus === "CANCELLED"
|
||||||
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
|
||||||
|
: nextStatus === "ON_HOLD"
|
||||||
|
? "Putting a work order on hold pauses expected execution and should capture the exact blocker so planning and shop-floor review stay aligned."
|
||||||
: nextStatus === "COMPLETE"
|
: nextStatus === "COMPLETE"
|
||||||
? "Completing the work order signals execution closure and can change readiness views across the system."
|
? "Completing the work order signals execution closure and can change readiness views across the system."
|
||||||
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
|
||||||
@@ -341,6 +348,7 @@ export function WorkOrderDetailPage() {
|
|||||||
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
|
||||||
nextStatus,
|
nextStatus,
|
||||||
});
|
});
|
||||||
|
setHoldReasonDraft(nextStatus === "ON_HOLD" ? workOrder.holdReason ?? "" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -391,6 +399,12 @@ export function WorkOrderDetailPage() {
|
|||||||
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3>
|
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3>
|
||||||
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
|
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
|
||||||
<div className="mt-3"><WorkOrderStatusBadge status={workOrder.status} /></div>
|
<div className="mt-3"><WorkOrderStatusBadge status={workOrder.status} /></div>
|
||||||
|
{workOrder.status === "ON_HOLD" && workOrder.holdReason ? (
|
||||||
|
<div className="mt-3 max-w-2xl rounded-[18px] border border-amber-300/60 bg-amber-50 px-3 py-3 text-sm text-amber-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em]">Current Hold Reason</div>
|
||||||
|
<div className="mt-2 whitespace-pre-line">{workOrder.holdReason}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
|
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
|
||||||
@@ -793,6 +807,12 @@ export function WorkOrderDetailPage() {
|
|||||||
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
|
||||||
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
confirmationLabel={pendingConfirmation?.confirmationLabel}
|
||||||
confirmationValue={pendingConfirmation?.confirmationValue}
|
confirmationValue={pendingConfirmation?.confirmationValue}
|
||||||
|
extraFieldLabel={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Hold reason" : undefined}
|
||||||
|
extraFieldPlaceholder={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? "Explain the blocker forcing this work order onto hold." : undefined}
|
||||||
|
extraFieldValue={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? holdReasonDraft : undefined}
|
||||||
|
extraFieldRequired={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
|
||||||
|
extraFieldMultiline={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD"}
|
||||||
|
onExtraFieldChange={pendingConfirmation?.kind === "status" && pendingConfirmation?.nextStatus === "ON_HOLD" ? setHoldReasonDraft : undefined}
|
||||||
isConfirming={
|
isConfirming={
|
||||||
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
|
||||||
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { permissions } from "@mrp/shared";
|
import { permissions } from "@mrp/shared";
|
||||||
import type { WorkOrderSummaryDto } from "@mrp/shared";
|
import type { ProjectMilestoneStatus, WorkOrderSummaryDto } from "@mrp/shared";
|
||||||
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
|
||||||
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
|
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -23,6 +23,7 @@ export function ProjectDetailPage() {
|
|||||||
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
|
||||||
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||||
const [status, setStatus] = useState("Loading project...");
|
const [status, setStatus] = useState("Loading project...");
|
||||||
|
const [updatingMilestoneId, setUpdatingMilestoneId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
|
||||||
|
|
||||||
@@ -107,6 +108,50 @@ export function ProjectDetailPage() {
|
|||||||
? "text-amber-600 dark:text-amber-300"
|
? "text-amber-600 dark:text-amber-300"
|
||||||
: "text-rose-600 dark:text-rose-300";
|
: "text-rose-600 dark:text-rose-300";
|
||||||
|
|
||||||
|
async function updateMilestoneStatus(milestoneId: string, nextStatus: ProjectMilestoneStatus) {
|
||||||
|
if (!token || !project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingMilestoneId(milestoneId);
|
||||||
|
setStatus("Updating milestone status...");
|
||||||
|
try {
|
||||||
|
const nextProject = await api.updateProjectMilestoneStatus(token, project.id, milestoneId, { status: nextStatus });
|
||||||
|
setProject(nextProject);
|
||||||
|
setStatus("Milestone status updated.");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "Unable to update milestone status.";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setUpdatingMilestoneId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestoneQuickActions(currentStatus: ProjectMilestoneStatus) {
|
||||||
|
if (currentStatus === "PLANNED") {
|
||||||
|
return [
|
||||||
|
{ status: "IN_PROGRESS" as const, label: "Start" },
|
||||||
|
{ status: "BLOCKED" as const, label: "Block" },
|
||||||
|
{ status: "COMPLETE" as const, label: "Complete" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (currentStatus === "IN_PROGRESS") {
|
||||||
|
return [
|
||||||
|
{ status: "BLOCKED" as const, label: "Block" },
|
||||||
|
{ status: "COMPLETE" as const, label: "Complete" },
|
||||||
|
{ status: "PLANNED" as const, label: "Reset" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (currentStatus === "BLOCKED") {
|
||||||
|
return [
|
||||||
|
{ status: "IN_PROGRESS" as const, label: "Resume" },
|
||||||
|
{ status: "COMPLETE" as const, label: "Complete" },
|
||||||
|
{ status: "PLANNED" as const, label: "Reset" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [{ status: "IN_PROGRESS" as const, label: "Reopen" }];
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
@@ -289,7 +334,7 @@ export function ProjectDetailPage() {
|
|||||||
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Milestones</p><p className="mt-2 text-sm text-muted">Track project checkpoints, blockers, and completion progress.</p></div>
|
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Milestones</p><p className="mt-2 text-sm text-muted">Track project checkpoints, blockers, and completion progress.</p></div>
|
||||||
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
||||||
</div>
|
</div>
|
||||||
{project.milestones.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 milestones are defined for this project yet.</div> : <div className="mt-6 space-y-3">{project.milestones.map((milestone) => (<div key={milestone.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="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div></div></div>))}</div>}
|
{project.milestones.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 milestones are defined for this project yet.</div> : <div className="mt-6 space-y-3">{project.milestones.map((milestone) => (<div key={milestone.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="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div>{canManage ? <div className="flex flex-wrap gap-2">{milestoneQuickActions(milestone.status).map((action) => (<button key={action.status} type="button" onClick={() => void updateMilestoneStatus(milestone.id, action.status)} disabled={updatingMilestoneId === milestone.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">{updatingMilestoneId === milestone.id ? "Saving..." : action.label}</button>))}</div> : null}</div></div>))}</div>}
|
||||||
</section>
|
</section>
|
||||||
{planning ? (
|
{planning ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ export function WorkbenchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
|
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
|
||||||
await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED");
|
await api.updateWorkOrderStatus(token, action.workOrderId, { status: "RELEASED" });
|
||||||
await refreshWorkbench("Workbench refreshed after release.");
|
await refreshWorkbench("Workbench refreshed after release.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "WorkOrder" ADD COLUMN "holdReason" TEXT;
|
||||||
@@ -653,6 +653,7 @@ model WorkOrder {
|
|||||||
warehouseId String
|
warehouseId String
|
||||||
locationId String
|
locationId String
|
||||||
status String
|
status String
|
||||||
|
holdReason String?
|
||||||
quantity Int
|
quantity Int
|
||||||
completedQuantity Int @default(0)
|
completedQuantity Int @default(0)
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const workOrderFiltersSchema = z.object({
|
|||||||
|
|
||||||
const statusUpdateSchema = z.object({
|
const statusUpdateSchema = z.object({
|
||||||
status: z.enum(workOrderStatuses),
|
status: z.enum(workOrderStatuses),
|
||||||
|
reason: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const materialIssueSchema = z.object({
|
const materialIssueSchema = z.object({
|
||||||
@@ -215,7 +216,7 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
|
|||||||
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
|
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id);
|
const result = await updateWorkOrderStatus(workOrderId, parsed.data, request.authUser?.id);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
WorkOrderOperationTimerInput,
|
WorkOrderOperationTimerInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
|
WorkOrderStatusUpdateInput,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ type WorkOrderRecord = {
|
|||||||
id: string;
|
id: string;
|
||||||
workOrderNumber: string;
|
workOrderNumber: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
holdReason: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
completedQuantity: number;
|
completedQuantity: number;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
@@ -390,6 +392,7 @@ function mapDetail(
|
|||||||
return {
|
return {
|
||||||
...mapSummary(record),
|
...mapSummary(record),
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
|
holdReason: record.holdReason,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
itemType: record.item.type,
|
itemType: record.item.type,
|
||||||
itemUnitOfMeasure: record.item.unitOfMeasure,
|
itemUnitOfMeasure: record.item.unitOfMeasure,
|
||||||
@@ -1294,12 +1297,18 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
|
|||||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus, actorId?: string | null) {
|
export async function updateWorkOrderStatus(
|
||||||
|
workOrderId: string,
|
||||||
|
payload: WorkOrderStatusUpdateInput,
|
||||||
|
actorId?: string | null
|
||||||
|
) {
|
||||||
const existing = await workOrderModel.findUnique({
|
const existing = await workOrderModel.findUnique({
|
||||||
where: { id: workOrderId },
|
where: { id: workOrderId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
workOrderNumber: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
holdReason: true,
|
||||||
quantity: true,
|
quantity: true,
|
||||||
completedQuantity: true,
|
completedQuantity: true,
|
||||||
},
|
},
|
||||||
@@ -1309,18 +1318,24 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
|
|||||||
return { ok: false as const, reason: "Work order was not found." };
|
return { ok: false as const, reason: "Work order was not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.status === "COMPLETE" && status !== "COMPLETE") {
|
if (existing.status === "COMPLETE" && payload.status !== "COMPLETE") {
|
||||||
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
|
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
|
if (payload.status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
|
||||||
return { ok: false as const, reason: "Use the completion action to finish a work order." };
|
return { ok: false as const, reason: "Use the completion action to finish a work order." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextHoldReason = payload.reason?.trim() ?? "";
|
||||||
|
if (payload.status === "ON_HOLD" && nextHoldReason.length === 0) {
|
||||||
|
return { ok: false as const, reason: "An On Hold reason is required before the work order can be paused." };
|
||||||
|
}
|
||||||
|
|
||||||
await workOrderModel.update({
|
await workOrderModel.update({
|
||||||
where: { id: workOrderId },
|
where: { id: workOrderId },
|
||||||
data: {
|
data: {
|
||||||
status,
|
status: payload.status,
|
||||||
|
holdReason: payload.status === "ON_HOLD" ? nextHoldReason : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1333,10 +1348,12 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
|
|||||||
entityType: "work-order",
|
entityType: "work-order",
|
||||||
entityId: workOrderId,
|
entityId: workOrderId,
|
||||||
action: "status.updated",
|
action: "status.updated",
|
||||||
summary: `Updated work order ${workOrder.workOrderNumber} to ${status}.`,
|
summary: `Updated work order ${workOrder.workOrderNumber} to ${payload.status}.`,
|
||||||
metadata: {
|
metadata: {
|
||||||
workOrderNumber: workOrder.workOrderNumber,
|
workOrderNumber: workOrder.workOrderNumber,
|
||||||
status,
|
previousStatus: existing.status,
|
||||||
|
status: payload.status,
|
||||||
|
holdReason: payload.status === "ON_HOLD" ? nextHoldReason : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
listProjectQuoteOptions,
|
listProjectQuoteOptions,
|
||||||
listProjectShipmentOptions,
|
listProjectShipmentOptions,
|
||||||
updateProject,
|
updateProject,
|
||||||
|
updateProjectMilestoneStatus,
|
||||||
} from "./service.js";
|
} from "./service.js";
|
||||||
|
|
||||||
const projectSchema = z.object({
|
const projectSchema = z.object({
|
||||||
@@ -51,6 +52,10 @@ const projectOptionQuerySchema = z.object({
|
|||||||
customerId: z.string().optional(),
|
customerId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const milestoneStatusSchema = z.object({
|
||||||
|
status: z.enum(projectMilestoneStatuses),
|
||||||
|
});
|
||||||
|
|
||||||
function getRouteParam(value: unknown) {
|
function getRouteParam(value: unknown) {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
@@ -147,3 +152,23 @@ projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]
|
|||||||
|
|
||||||
return ok(response, result.project);
|
return ok(response, result.project);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
projectsRouter.patch("/:projectId/milestones/:milestoneId/status", requirePermissions([permissions.projectsWrite]), async (request, response) => {
|
||||||
|
const projectId = getRouteParam(request.params.projectId);
|
||||||
|
const milestoneId = getRouteParam(request.params.milestoneId);
|
||||||
|
if (!projectId || !milestoneId) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Project or milestone id is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = milestoneStatusSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Project milestone status payload is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateProjectMilestoneStatus(projectId, milestoneId, parsed.data, request.authUser?.id);
|
||||||
|
if (!result.ok) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(response, result.project);
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
ProjectInput,
|
ProjectInput,
|
||||||
ProjectMilestoneDto,
|
ProjectMilestoneDto,
|
||||||
ProjectMilestoneInput,
|
ProjectMilestoneInput,
|
||||||
|
ProjectMilestoneStatus,
|
||||||
|
ProjectMilestoneStatusUpdateInput,
|
||||||
ProjectOwnerOptionDto,
|
ProjectOwnerOptionDto,
|
||||||
ProjectPriority,
|
ProjectPriority,
|
||||||
ProjectRollupDto,
|
ProjectRollupDto,
|
||||||
@@ -1266,3 +1268,60 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
|
|||||||
}
|
}
|
||||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProjectMilestoneStatus(
|
||||||
|
projectId: string,
|
||||||
|
milestoneId: string,
|
||||||
|
payload: ProjectMilestoneStatusUpdateInput,
|
||||||
|
actorId?: string | null
|
||||||
|
) {
|
||||||
|
const existing = await prisma.projectMilestone.findUnique({
|
||||||
|
where: { id: milestoneId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
completedAt: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectNumber: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing || existing.projectId !== projectId) {
|
||||||
|
return { ok: false as const, reason: "Project milestone was not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = payload.status as ProjectMilestoneStatus;
|
||||||
|
await prisma.projectMilestone.update({
|
||||||
|
where: { id: milestoneId },
|
||||||
|
data: {
|
||||||
|
status: nextStatus,
|
||||||
|
completedAt: nextStatus === "COMPLETE" ? existing.completedAt ?? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await getProjectById(projectId);
|
||||||
|
if (project) {
|
||||||
|
await logAuditEvent({
|
||||||
|
actorId,
|
||||||
|
entityType: "project",
|
||||||
|
entityId: projectId,
|
||||||
|
action: "milestone.status.updated",
|
||||||
|
summary: `Updated milestone ${existing.title} on ${existing.project.projectNumber} to ${nextStatus}.`,
|
||||||
|
metadata: {
|
||||||
|
projectNumber: existing.project.projectNumber,
|
||||||
|
milestoneId,
|
||||||
|
milestoneTitle: existing.title,
|
||||||
|
previousStatus: existing.status,
|
||||||
|
status: nextStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export interface WorkOrderCompletionDto {
|
|||||||
|
|
||||||
export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
|
export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
|
||||||
notes: string;
|
notes: string;
|
||||||
|
holdReason: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
itemType: string;
|
itemType: string;
|
||||||
itemUnitOfMeasure: string;
|
itemUnitOfMeasure: string;
|
||||||
@@ -218,3 +219,8 @@ export interface WorkOrderOperationTimerInput {
|
|||||||
action: "START" | "STOP";
|
action: "START" | "STOP";
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderStatusUpdateInput {
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ export interface ProjectMilestoneInput {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectMilestoneStatusUpdateInput {
|
||||||
|
status: ProjectMilestoneStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectDetailDto extends ProjectSummaryDto {
|
export interface ProjectDetailDto extends ProjectSummaryDto {
|
||||||
notes: string;
|
notes: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user