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

@@ -6,6 +6,8 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
### Added ### Added
- 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 - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support - Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
- Dedicated inventory-item thumbnail display on the item detail page - Dedicated inventory-item thumbnail display on the item detail page
@@ -61,6 +63,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
### Changed ### Changed
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider - Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item - Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
- Admin user edits now normalize blank passwords to null and surface save failures instead of appearing unresponsive - Admin user edits now normalize blank passwords to null and surface save failures instead of appearing unresponsive

View File

@@ -26,7 +26,7 @@ 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 packing slips, shipping labels, bills of lading, and logistics attachments - shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
- planning gantt timelines with live project and manufacturing schedule data - planning gantt timelines with live project and manufacturing schedule data
- 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
@@ -63,13 +63,13 @@ Current completed foundation areas:
Near-term priorities: Near-term priorities:
1. Project milestones and project-side rollup visibility 1. Deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views 2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
Revisit / deferred items: Revisit / deferred items:
- local Windows Prisma migration reliability - local Windows Prisma migration reliability
- project milestones and project-side rollup visibility - deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
Dashboard direction: Dashboard direction:
@@ -91,7 +91,7 @@ Navigation direction:
## Projects 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, 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, notes, commercial document links, shipment links, attachments, and dashboard visibility.
Current interactions: Current interactions:
@@ -99,6 +99,7 @@ Current interactions:
- Sales: quotes and sales orders can already attach to projects - Sales: quotes and sales orders can already attach to projects
- Shipping: shipments tied to project deliverables are visible from the project record - Shipping: shipments tied to project deliverables are visible from the project record
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets - Dashboard: projects now contribute status, risk, backlog, and overdue widgets
- Detail/List UX: projects now surface milestone progress and linked execution rollups
Next expansion areas: Next expansion areas:
@@ -385,7 +386,7 @@ The current admin operations slice supports:
Current follow-up direction: Current follow-up direction:
- revision comparison UX for changed sales and purchasing documents - revision comparison UX for changed sales and purchasing documents
- project milestones and project-side rollup visibility - deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
## UI Notes ## UI Notes

View File

@@ -14,7 +14,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
## Near-term priority order ## Near-term priority order
1. Project milestones, project rollups, and deeper project-side execution visibility 1. Deeper project-side execution visibility, cost/supply rollups, and project cockpit refinement
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views 2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
3. Dashboard KPI, alert, recent-activity, and exception-widget expansion 3. Dashboard KPI, alert, recent-activity, and exception-widget expansion
4. Longer-term session history and audit depth beyond the current review filtering and retention cleanup 4. Longer-term session history and audit depth beyond the current review filtering and retention cleanup
@@ -75,8 +75,8 @@ This file tracks work that still needs to be completed. Shipped phase history an
### Projects and program management ### Projects and program management
- Project document hub for drawings, support files, correspondence, and revision references - Project document hub for drawings, support files, correspondence, and revision references
- Milestones, checkpoints, and non-manufacturing work packages for long-running execution tracking - Non-manufacturing work packages for long-running execution tracking
- Project-level commercial, material, schedule, and delivery rollups - Deeper project-level commercial, material, schedule, and delivery rollups
- Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication - Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication
- Project templates for repeatable build types - Project templates for repeatable build types
- Project-specific attachment bundles and revision snapshots - Project-specific attachment bundles and revision snapshots

View File

@@ -33,6 +33,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments - Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
- 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
- Project milestones and project-side milestone/work-order rollups
- 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, material issue posting, completion posting, and work-order attachments - 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 - Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
@@ -68,7 +69,8 @@ This file tracks roadmap phases, slices, and major foundations that have already
### Phase 5: Projects and program management ### Phase 5: Projects and program management
- Project records with customer linkage, status, owner, priority, due dates, and notes - Project records with customer linkage, status, owner, priority, due dates, milestones, and notes
- Project milestone status tracking and project-side milestone/work-order rollups
- Project-to-quote, sales-order, and shipment linkage for delivery context - Project-to-quote, sales-order, and shipment linkage for delivery context
- Project attachments through the shared file pipeline - Project attachments through the shared file pipeline
- Project list/detail/create/edit flows and dashboard visibility - Project list/detail/create/edit flows and dashboard visibility

View File

@@ -8,6 +8,7 @@ import { Link, useParams } from "react-router-dom";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel"; import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { projectMilestoneStatusPalette } from "./config";
import { ProjectPriorityBadge } from "./ProjectPriorityBadge"; import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
import { ProjectStatusBadge } from "./ProjectStatusBadge"; import { ProjectStatusBadge } from "./ProjectStatusBadge";
@@ -73,6 +74,27 @@ export function ProjectDetailPage() {
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article> <article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article> <article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
</section> </section>
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Milestones</p>
<div className="mt-2 text-base font-bold text-text">{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}</div>
<div className="mt-1 text-xs text-muted">{project.rollups.openMilestoneCount} open</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Milestones</p>
<div className="mt-2 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Linked Work Orders</p>
<div className="mt-2 text-base font-bold text-text">{project.rollups.workOrderCount}</div>
<div className="mt-1 text-xs text-muted">{project.rollups.activeWorkOrderCount} active</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work Orders</p>
<div className="mt-2 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div>
<div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]"> <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"> <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> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p>
@@ -104,6 +126,48 @@ export function ProjectDetailPage() {
</div> </div>
</div> </div>
</section> </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">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}
</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>
)}
</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">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Readiness</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Readiness</p>

View File

@@ -2,6 +2,7 @@ import type {
ProjectCustomerOptionDto, ProjectCustomerOptionDto,
ProjectDocumentOptionDto, ProjectDocumentOptionDto,
ProjectInput, ProjectInput,
ProjectMilestoneInput,
ProjectOwnerOptionDto, ProjectOwnerOptionDto,
ProjectShipmentOptionDto, ProjectShipmentOptionDto,
} from "@mrp/shared/dist/projects/types.js"; } from "@mrp/shared/dist/projects/types.js";
@@ -11,7 +12,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config"; import { emptyProjectInput, projectMilestoneStatusOptions, projectPriorityOptions, projectStatusOptions } from "./config";
type ProjectPendingConfirmation = type ProjectPendingConfirmation =
| { kind: "change-customer"; customerId: string; customerName: string } | { kind: "change-customer"; customerId: string; customerName: string }
@@ -43,6 +44,13 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null); const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
function reindexMilestones(milestones: ProjectMilestoneInput[]) {
return milestones.map((milestone, index) => ({
...milestone,
sortOrder: index * 10,
}));
}
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
return; return;
@@ -83,6 +91,14 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
ownerId: project.ownerId, ownerId: project.ownerId,
dueDate: project.dueDate, dueDate: project.dueDate,
notes: project.notes, notes: project.notes,
milestones: project.milestones.map((milestone) => ({
id: milestone.id,
title: milestone.title,
status: milestone.status,
dueDate: milestone.dueDate,
notes: milestone.notes,
sortOrder: milestone.sortOrder,
})),
}); });
setCustomerSearchTerm(project.customerName); setCustomerSearchTerm(project.customerName);
setOwnerSearchTerm(project.ownerName ?? ""); setOwnerSearchTerm(project.ownerName ?? "");
@@ -162,6 +178,44 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? ""); setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
} }
function addMilestone() {
setForm((current) => ({
...current,
milestones: reindexMilestones([
...current.milestones,
{
id: null,
title: "",
status: "PLANNED",
dueDate: current.dueDate,
notes: "",
sortOrder: current.milestones.length * 10,
},
]),
}));
}
function updateMilestone<Key extends keyof ProjectMilestoneInput>(index: number, key: Key, value: ProjectMilestoneInput[Key]) {
setForm((current) => ({
...current,
milestones: current.milestones.map((milestone, milestoneIndex) =>
milestoneIndex === index
? {
...milestone,
[key]: value,
}
: milestone
),
}));
}
function removeMilestone(index: number) {
setForm((current) => ({
...current,
milestones: reindexMilestones(current.milestones.filter((_, milestoneIndex) => milestoneIndex !== index)),
}));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
@@ -477,6 +531,76 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label> </label>
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold text-text">Milestones</div>
<div className="mt-1 text-xs text-muted">Track checkpoints, due dates, and blocked work inside the project record.</div>
</div>
<button type="button" onClick={addMilestone} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add milestone
</button>
</div>
{form.milestones.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-surface/80 px-3 py-4 text-sm text-muted">
No milestones added yet.
</div>
) : (
<div className="space-y-3">
{form.milestones.map((milestone, milestoneIndex) => (
<div key={milestone.id ?? `new-milestone-${milestoneIndex}`} className="rounded-[18px] border border-line/70 bg-surface/80 p-3">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_180px_180px]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Milestone</span>
<input
value={milestone.title}
onChange={(event) => updateMilestone(milestoneIndex, "title", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={milestone.status}
onChange={(event) => updateMilestone(milestoneIndex, "status", event.target.value as ProjectMilestoneInput["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{projectMilestoneStatusOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Due date</span>
<input
type="date"
value={milestone.dueDate ? milestone.dueDate.slice(0, 10) : ""}
onChange={(event) => updateMilestone(milestoneIndex, "dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_120px]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<textarea
value={milestone.notes}
onChange={(event) => updateMilestone(milestoneIndex, "notes", event.target.value)}
rows={3}
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<div className="flex items-end">
<button type="button" onClick={() => removeMilestone(milestoneIndex)} className="w-full rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span> <span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"> <button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">

View File

@@ -86,6 +86,7 @@ export function ProjectListPage() {
<tr> <tr>
<th className="px-2 py-2">Project</th> <th className="px-2 py-2">Project</th>
<th className="px-2 py-2">Customer</th> <th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Rollups</th>
<th className="px-2 py-2">Owner</th> <th className="px-2 py-2">Owner</th>
<th className="px-2 py-2">Status</th> <th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Priority</th> <th className="px-2 py-2">Priority</th>
@@ -100,6 +101,11 @@ export function ProjectListPage() {
<div className="mt-1 text-xs text-muted">{project.name}</div> <div className="mt-1 text-xs text-muted">{project.name}</div>
</td> </td>
<td className="px-2 py-2 text-muted">{project.customerName}</td> <td className="px-2 py-2 text-muted">{project.customerName}</td>
<td className="px-2 py-2 text-xs text-muted">
<div>{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount} milestones</div>
<div>{project.rollups.activeWorkOrderCount} active WO</div>
<div>{project.rollups.overdueMilestoneCount + project.rollups.overdueWorkOrderCount} overdue items</div>
</td>
<td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td> <td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td>
<td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td> <td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td>
<td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td> <td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td>

View File

@@ -1,4 +1,4 @@
import type { ProjectInput, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js"; import type { ProjectInput, ProjectMilestoneStatus, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js";
export const projectStatusOptions: Array<{ value: ProjectStatus; label: string }> = [ export const projectStatusOptions: Array<{ value: ProjectStatus; label: string }> = [
{ value: "PLANNED", label: "Planned" }, { value: "PLANNED", label: "Planned" },
@@ -40,6 +40,20 @@ export const projectPriorityPalette: Record<ProjectPriority, string> = {
CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300", CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
}; };
export const projectMilestoneStatusOptions: Array<{ value: ProjectMilestoneStatus; label: string }> = [
{ value: "PLANNED", label: "Planned" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "BLOCKED", label: "Blocked" },
{ value: "COMPLETE", label: "Complete" },
];
export const projectMilestoneStatusPalette: Record<ProjectMilestoneStatus, string> = {
PLANNED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
IN_PROGRESS: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
BLOCKED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
COMPLETE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const emptyProjectInput: ProjectInput = { export const emptyProjectInput: ProjectInput = {
name: "", name: "",
status: "PLANNED", status: "PLANNED",
@@ -51,4 +65,5 @@ export const emptyProjectInput: ProjectInput = {
ownerId: null, ownerId: null,
dueDate: null, dueDate: null,
notes: "", notes: "",
milestones: [],
}; };

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) shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull) owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
workOrders WorkOrder[] workOrders WorkOrder[]
milestones ProjectMilestone[]
@@index([customerId, createdAt]) @@index([customerId, createdAt])
@@index([ownerId, dueDate]) @@index([ownerId, dueDate])
@@index([status, priority]) @@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 { model WorkOrder {
id String @id @default(cuid()) id String @id @default(cuid())
workOrderNumber String @unique 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 { Router } from "express";
import { z } from "zod"; import { z } from "zod";
@@ -27,6 +27,16 @@ const projectSchema = z.object({
ownerId: z.string().trim().min(1).nullable(), ownerId: z.string().trim().min(1).nullable(),
dueDate: z.string().datetime().nullable(), dueDate: z.string().datetime().nullable(),
notes: z.string(), 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({ const projectListQuerySchema = z.object({

View File

@@ -3,11 +3,15 @@ import type {
ProjectDetailDto, ProjectDetailDto,
ProjectDocumentOptionDto, ProjectDocumentOptionDto,
ProjectInput, ProjectInput,
ProjectMilestoneDto,
ProjectMilestoneInput,
ProjectOwnerOptionDto, ProjectOwnerOptionDto,
ProjectPriority, ProjectPriority,
ProjectRollupDto,
ProjectShipmentOptionDto, ProjectShipmentOptionDto,
ProjectStatus, ProjectStatus,
ProjectSummaryDto, ProjectSummaryDto,
WorkOrderStatus,
} from "@mrp/shared"; } from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js"; import { logAuditEvent } from "../../lib/audit.js";
@@ -15,6 +19,16 @@ import { prisma } from "../../lib/prisma.js";
const projectModel = (prisma as any).project; 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 = { type ProjectRecord = {
id: string; id: string;
projectNumber: string; projectNumber: string;
@@ -48,12 +62,58 @@ type ProjectRecord = {
id: string; id: string;
shipmentNumber: string; shipmentNumber: string;
} | null; } | null;
milestones: ProjectMilestoneRecord[];
workOrders: Array<{
id: string;
status: string;
dueDate: Date | null;
}>;
}; };
function getOwnerName(owner: ProjectRecord["owner"]) { function getOwnerName(owner: ProjectRecord["owner"]) {
return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null; 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 { function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
return { return {
id: record.id, id: record.id,
@@ -67,6 +127,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
ownerName: getOwnerName(record.owner), ownerName: getOwnerName(record.owner),
dueDate: record.dueDate ? record.dueDate.toISOString() : null, dueDate: record.dueDate ? record.dueDate.toISOString() : null,
updatedAt: record.updatedAt.toISOString(), updatedAt: record.updatedAt.toISOString(),
rollups: buildProjectRollups(record),
}; };
} }
@@ -83,6 +144,7 @@ function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
shipmentNumber: record.shipment?.shipmentNumber ?? null, shipmentNumber: record.shipment?.shipmentNumber ?? null,
customerEmail: record.customer.email, customerEmail: record.customer.email,
customerPhone: record.customer.phone, customerPhone: record.customer.phone,
milestones: record.milestones.map(mapProjectMilestone),
}; };
} }
@@ -121,6 +183,25 @@ function buildInclude() {
shipmentNumber: true, 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 }; 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[]> { export async function listProjectCustomerOptions(): Promise<ProjectCustomerOptionDto[]> {
const customers = await prisma.customer.findMany({ const customers = await prisma.customer.findMany({
where: { where: {
@@ -364,23 +521,29 @@ export async function createProject(payload: ProjectInput, actorId?: string | nu
} }
const projectNumber = await nextProjectNumber(); const projectNumber = await nextProjectNumber();
const created = await projectModel.create({ const created = await prisma.$transaction(async (transaction) => {
data: { const transactionProjectModel = (transaction as any).project;
projectNumber, const createdProject = await transactionProjectModel.create({
name: payload.name.trim(), data: {
status: payload.status, projectNumber,
priority: payload.priority, name: payload.name.trim(),
customerId: payload.customerId, status: payload.status,
salesQuoteId: payload.salesQuoteId, priority: payload.priority,
salesOrderId: payload.salesOrderId, customerId: payload.customerId,
shipmentId: payload.shipmentId, salesQuoteId: payload.salesQuoteId,
ownerId: payload.ownerId, salesOrderId: payload.salesOrderId,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null, shipmentId: payload.shipmentId,
notes: payload.notes, ownerId: payload.ownerId,
}, dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
select: { notes: payload.notes,
id: true, },
}, select: {
id: true,
},
});
await syncProjectMilestones(transaction, createdProject.id, payload.milestones ?? []);
return createdProject;
}); });
const project = await getProjectById(created.id); const project = await getProjectById(created.id);
@@ -396,6 +559,7 @@ export async function createProject(payload: ProjectInput, actorId?: string | nu
customerId: project.customerId, customerId: project.customerId,
status: project.status, status: project.status,
priority: project.priority, 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 }; return { ok: false as const, reason: validated.reason };
} }
await projectModel.update({ await prisma.$transaction(async (transaction) => {
where: { id: projectId }, const transactionProjectModel = (transaction as any).project;
data: { await transactionProjectModel.update({
name: payload.name.trim(), where: { id: projectId },
status: payload.status, data: {
priority: payload.priority, name: payload.name.trim(),
customerId: payload.customerId, status: payload.status,
salesQuoteId: payload.salesQuoteId, priority: payload.priority,
salesOrderId: payload.salesOrderId, customerId: payload.customerId,
shipmentId: payload.shipmentId, salesQuoteId: payload.salesQuoteId,
ownerId: payload.ownerId, salesOrderId: payload.salesOrderId,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null, shipmentId: payload.shipmentId,
notes: payload.notes, ownerId: payload.ownerId,
}, dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
select: { notes: payload.notes,
id: true, },
}, select: {
id: true,
},
});
await syncProjectMilestones(transaction, projectId, payload.milestones ?? []);
}); });
const project = await getProjectById(projectId); const project = await getProjectById(projectId);
@@ -449,6 +618,7 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
customerId: project.customerId, customerId: project.customerId,
status: project.status, status: project.status,
priority: project.priority, priority: project.priority,
milestoneCount: project.rollups.milestoneCount,
}, },
}); });
} }

View File

@@ -1,8 +1,10 @@
export const projectStatuses = ["PLANNED", "ACTIVE", "ON_HOLD", "AT_RISK", "COMPLETE"] as const; export const projectStatuses = ["PLANNED", "ACTIVE", "ON_HOLD", "AT_RISK", "COMPLETE"] as const;
export const projectPriorities = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] as const; export const projectPriorities = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] as const;
export const projectMilestoneStatuses = ["PLANNED", "IN_PROGRESS", "BLOCKED", "COMPLETE"] as const;
export type ProjectStatus = (typeof projectStatuses)[number]; export type ProjectStatus = (typeof projectStatuses)[number];
export type ProjectPriority = (typeof projectPriorities)[number]; export type ProjectPriority = (typeof projectPriorities)[number];
export type ProjectMilestoneStatus = (typeof projectMilestoneStatuses)[number];
export interface ProjectCustomerOptionDto { export interface ProjectCustomerOptionDto {
id: string; id: string;
@@ -43,6 +45,37 @@ export interface ProjectSummaryDto {
ownerName: string | null; ownerName: string | null;
dueDate: string | null; dueDate: string | null;
updatedAt: string; updatedAt: string;
rollups: ProjectRollupDto;
}
export interface ProjectRollupDto {
milestoneCount: number;
completedMilestoneCount: number;
openMilestoneCount: number;
overdueMilestoneCount: number;
workOrderCount: number;
activeWorkOrderCount: number;
completedWorkOrderCount: number;
overdueWorkOrderCount: number;
}
export interface ProjectMilestoneDto {
id: string;
title: string;
status: ProjectMilestoneStatus;
dueDate: string | null;
completedAt: string | null;
notes: string;
sortOrder: number;
}
export interface ProjectMilestoneInput {
id?: string | null;
title: string;
status: ProjectMilestoneStatus;
dueDate: string | null;
notes: string;
sortOrder: number;
} }
export interface ProjectDetailDto extends ProjectSummaryDto { export interface ProjectDetailDto extends ProjectSummaryDto {
@@ -56,6 +89,7 @@ export interface ProjectDetailDto extends ProjectSummaryDto {
shipmentNumber: string | null; shipmentNumber: string | null;
customerEmail: string; customerEmail: string;
customerPhone: string; customerPhone: string;
milestones: ProjectMilestoneDto[];
} }
export interface ProjectInput { export interface ProjectInput {
@@ -69,4 +103,5 @@ export interface ProjectInput {
ownerId: string | null; ownerId: string | null;
dueDate: string | null; dueDate: string | null;
notes: string; notes: string;
milestones: ProjectMilestoneInput[];
} }