projects milestones
This commit is contained in:
@@ -6,6 +6,8 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
||||
|
||||
### 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
|
||||
- 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
|
||||
@@ -61,6 +63,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
||||
|
||||
### 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
|
||||
- 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
|
||||
|
||||
11
README.md
11
README.md
@@ -26,7 +26,7 @@ Current foundation scope includes:
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
@@ -63,13 +63,13 @@ Current completed foundation areas:
|
||||
|
||||
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
|
||||
|
||||
Revisit / deferred items:
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -91,7 +91,7 @@ Navigation direction:
|
||||
|
||||
## Projects Direction
|
||||
|
||||
Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, 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:
|
||||
|
||||
@@ -99,6 +99,7 @@ Current interactions:
|
||||
- Sales: quotes and sales orders can already attach to projects
|
||||
- Shipping: shipments tied to project deliverables are visible from the project record
|
||||
- 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:
|
||||
|
||||
@@ -385,7 +386,7 @@ The current admin operations slice supports:
|
||||
Current follow-up direction:
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -75,8 +75,8 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
||||
### Projects and program management
|
||||
|
||||
- Project document hub for drawings, support files, correspondence, and revision references
|
||||
- Milestones, checkpoints, and non-manufacturing work packages for long-running execution tracking
|
||||
- Project-level commercial, material, schedule, and delivery rollups
|
||||
- Non-manufacturing work packages for long-running execution tracking
|
||||
- Deeper project-level commercial, material, schedule, and delivery rollups
|
||||
- Cross-functional visibility for engineering, purchasing, manufacturing, shipping, and customer communication
|
||||
- Project templates for repeatable build types
|
||||
- Project-specific attachment bundles and revision snapshots
|
||||
|
||||
@@ -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
|
||||
- Logistics attachments directly on shipment records
|
||||
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||
- Project milestones and project-side milestone/work-order rollups
|
||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
|
||||
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
|
||||
@@ -68,7 +69,8 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
||||
|
||||
### 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 attachments through the shared file pipeline
|
||||
- Project list/detail/create/edit flows and dashboard visibility
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Link, useParams } from "react-router-dom";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { projectMilestoneStatusPalette } from "./config";
|
||||
import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
|
||||
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">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
||||
</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)]">
|
||||
<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>
|
||||
@@ -104,6 +126,48 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">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 ? (
|
||||
<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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ProjectCustomerOptionDto,
|
||||
ProjectDocumentOptionDto,
|
||||
ProjectInput,
|
||||
ProjectMilestoneInput,
|
||||
ProjectOwnerOptionDto,
|
||||
ProjectShipmentOptionDto,
|
||||
} 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 { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
|
||||
import { emptyProjectInput, projectMilestoneStatusOptions, projectPriorityOptions, projectStatusOptions } from "./config";
|
||||
|
||||
type ProjectPendingConfirmation =
|
||||
| { kind: "change-customer"; customerId: string; customerName: string }
|
||||
@@ -43,6 +44,13 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
|
||||
|
||||
function reindexMilestones(milestones: ProjectMilestoneInput[]) {
|
||||
return milestones.map((milestone, index) => ({
|
||||
...milestone,
|
||||
sortOrder: index * 10,
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
@@ -83,6 +91,14 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
ownerId: project.ownerId,
|
||||
dueDate: project.dueDate,
|
||||
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);
|
||||
setOwnerSearchTerm(project.ownerName ?? "");
|
||||
@@ -162,6 +178,44 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
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>
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -86,6 +86,7 @@ export function ProjectListPage() {
|
||||
<tr>
|
||||
<th className="px-2 py-2">Project</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">Status</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>
|
||||
</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"><ProjectStatusBadge status={project.status} /></td>
|
||||
<td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td>
|
||||
|
||||
@@ -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 }> = [
|
||||
{ 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",
|
||||
};
|
||||
|
||||
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 = {
|
||||
name: "",
|
||||
status: "PLANNED",
|
||||
@@ -51,4 +65,5 @@ export const emptyProjectInput: ProjectInput = {
|
||||
ownerId: null,
|
||||
dueDate: null,
|
||||
notes: "",
|
||||
milestones: [],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
@@ -583,12 +583,30 @@ model Project {
|
||||
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
|
||||
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
workOrders WorkOrder[]
|
||||
milestones ProjectMilestone[]
|
||||
|
||||
@@index([customerId, createdAt])
|
||||
@@index([ownerId, dueDate])
|
||||
@@index([status, priority])
|
||||
}
|
||||
|
||||
model ProjectMilestone {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
title String
|
||||
status String
|
||||
dueDate DateTime?
|
||||
completedAt DateTime?
|
||||
notes String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([projectId, sortOrder])
|
||||
@@index([projectId, dueDate])
|
||||
}
|
||||
|
||||
model WorkOrder {
|
||||
id String @id @default(cuid())
|
||||
workOrderNumber String @unique
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { permissions, projectPriorities, projectStatuses } from "@mrp/shared";
|
||||
import { permissions, projectMilestoneStatuses, projectPriorities, projectStatuses } from "@mrp/shared";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -27,6 +27,16 @@ const projectSchema = z.object({
|
||||
ownerId: z.string().trim().min(1).nullable(),
|
||||
dueDate: z.string().datetime().nullable(),
|
||||
notes: z.string(),
|
||||
milestones: z.array(
|
||||
z.object({
|
||||
id: z.string().trim().min(1).nullable().optional(),
|
||||
title: z.string().trim().min(1).max(160),
|
||||
status: z.enum(projectMilestoneStatuses),
|
||||
dueDate: z.string().datetime().nullable(),
|
||||
notes: z.string(),
|
||||
sortOrder: z.number().int(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const projectListQuerySchema = z.object({
|
||||
|
||||
@@ -3,11 +3,15 @@ import type {
|
||||
ProjectDetailDto,
|
||||
ProjectDocumentOptionDto,
|
||||
ProjectInput,
|
||||
ProjectMilestoneDto,
|
||||
ProjectMilestoneInput,
|
||||
ProjectOwnerOptionDto,
|
||||
ProjectPriority,
|
||||
ProjectRollupDto,
|
||||
ProjectShipmentOptionDto,
|
||||
ProjectStatus,
|
||||
ProjectSummaryDto,
|
||||
WorkOrderStatus,
|
||||
} from "@mrp/shared";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
@@ -15,6 +19,16 @@ import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const projectModel = (prisma as any).project;
|
||||
|
||||
type ProjectMilestoneRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
dueDate: Date | null;
|
||||
completedAt: Date | null;
|
||||
notes: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type ProjectRecord = {
|
||||
id: string;
|
||||
projectNumber: string;
|
||||
@@ -48,12 +62,58 @@ type ProjectRecord = {
|
||||
id: string;
|
||||
shipmentNumber: string;
|
||||
} | null;
|
||||
milestones: ProjectMilestoneRecord[];
|
||||
workOrders: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
dueDate: Date | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function getOwnerName(owner: ProjectRecord["owner"]) {
|
||||
return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null;
|
||||
}
|
||||
|
||||
function mapProjectMilestone(record: ProjectMilestoneRecord): ProjectMilestoneDto {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
status: record.status as ProjectMilestoneDto["status"],
|
||||
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
|
||||
completedAt: record.completedAt ? record.completedAt.toISOString() : null,
|
||||
notes: record.notes,
|
||||
sortOrder: record.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectRollups(record: ProjectRecord): ProjectRollupDto {
|
||||
const now = Date.now();
|
||||
const milestoneCount = record.milestones.length;
|
||||
const completedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "COMPLETE").length;
|
||||
const overdueMilestoneCount = record.milestones.filter(
|
||||
(milestone) => milestone.status !== "COMPLETE" && milestone.dueDate && milestone.dueDate.getTime() < now
|
||||
).length;
|
||||
const workOrderCount = record.workOrders.length;
|
||||
const completedWorkOrderCount = record.workOrders.filter((workOrder) => workOrder.status === "COMPLETE").length;
|
||||
const activeStatuses = new Set<WorkOrderStatus>(["RELEASED", "IN_PROGRESS", "ON_HOLD"]);
|
||||
const closedStatuses = new Set<WorkOrderStatus>(["COMPLETE", "CANCELLED"]);
|
||||
const activeWorkOrderCount = record.workOrders.filter((workOrder) => activeStatuses.has(workOrder.status as WorkOrderStatus)).length;
|
||||
const overdueWorkOrderCount = record.workOrders.filter(
|
||||
(workOrder) => !closedStatuses.has(workOrder.status as WorkOrderStatus) && workOrder.dueDate && workOrder.dueDate.getTime() < now
|
||||
).length;
|
||||
|
||||
return {
|
||||
milestoneCount,
|
||||
completedMilestoneCount,
|
||||
openMilestoneCount: milestoneCount - completedMilestoneCount,
|
||||
overdueMilestoneCount,
|
||||
workOrderCount,
|
||||
activeWorkOrderCount,
|
||||
completedWorkOrderCount,
|
||||
overdueWorkOrderCount,
|
||||
};
|
||||
}
|
||||
|
||||
function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -67,6 +127,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
|
||||
ownerName: getOwnerName(record.owner),
|
||||
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
rollups: buildProjectRollups(record),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +144,7 @@ function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
|
||||
shipmentNumber: record.shipment?.shipmentNumber ?? null,
|
||||
customerEmail: record.customer.email,
|
||||
customerPhone: record.customer.phone,
|
||||
milestones: record.milestones.map(mapProjectMilestone),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,6 +183,25 @@ function buildInclude() {
|
||||
shipmentNumber: true,
|
||||
},
|
||||
},
|
||||
milestones: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
dueDate: true,
|
||||
completedAt: true,
|
||||
notes: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
workOrders: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
dueDate: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +285,82 @@ async function validateProjectInput(payload: ProjectInput) {
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
function normalizeMilestoneInput(milestone: ProjectMilestoneInput, index: number) {
|
||||
const title = milestone.title.trim();
|
||||
const notes = milestone.notes.trim();
|
||||
const sortOrder = Number.isFinite(milestone.sortOrder) ? milestone.sortOrder : index * 10;
|
||||
|
||||
return {
|
||||
id: milestone.id?.trim() || null,
|
||||
title,
|
||||
status: milestone.status,
|
||||
dueDate: milestone.dueDate ? new Date(milestone.dueDate) : null,
|
||||
notes,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
async function syncProjectMilestones(transaction: any, projectId: string, milestones: ProjectMilestoneInput[]) {
|
||||
const milestoneModel = (transaction as any).projectMilestone;
|
||||
const existingMilestones = await milestoneModel.findMany({
|
||||
where: { projectId },
|
||||
select: {
|
||||
id: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingById = new Map<string, { completedAt: Date | null }>(
|
||||
existingMilestones.map((milestone: { id: string; completedAt: Date | null }) => [milestone.id, { completedAt: milestone.completedAt }])
|
||||
);
|
||||
|
||||
const normalized = milestones.map(normalizeMilestoneInput);
|
||||
const retainedIds = normalized.flatMap((milestone) => (milestone.id && existingById.has(milestone.id) ? [milestone.id] : []));
|
||||
|
||||
await milestoneModel.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
...(retainedIds.length > 0 ? { id: { notIn: retainedIds } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (retainedIds.length === 0) {
|
||||
await milestoneModel.deleteMany({ where: { projectId } });
|
||||
}
|
||||
|
||||
for (const milestone of normalized) {
|
||||
const existing = milestone.id ? existingById.get(milestone.id) : null;
|
||||
const completedAt = milestone.status === "COMPLETE" ? existing?.completedAt ?? new Date() : null;
|
||||
|
||||
if (milestone.id && existing) {
|
||||
await milestoneModel.update({
|
||||
where: { id: milestone.id },
|
||||
data: {
|
||||
title: milestone.title,
|
||||
status: milestone.status,
|
||||
dueDate: milestone.dueDate,
|
||||
completedAt,
|
||||
notes: milestone.notes,
|
||||
sortOrder: milestone.sortOrder,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await milestoneModel.create({
|
||||
data: {
|
||||
projectId,
|
||||
title: milestone.title,
|
||||
status: milestone.status,
|
||||
dueDate: milestone.dueDate,
|
||||
completedAt,
|
||||
notes: milestone.notes,
|
||||
sortOrder: milestone.sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProjectCustomerOptions(): Promise<ProjectCustomerOptionDto[]> {
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: {
|
||||
@@ -364,7 +521,9 @@ export async function createProject(payload: ProjectInput, actorId?: string | nu
|
||||
}
|
||||
|
||||
const projectNumber = await nextProjectNumber();
|
||||
const created = await projectModel.create({
|
||||
const created = await prisma.$transaction(async (transaction) => {
|
||||
const transactionProjectModel = (transaction as any).project;
|
||||
const createdProject = await transactionProjectModel.create({
|
||||
data: {
|
||||
projectNumber,
|
||||
name: payload.name.trim(),
|
||||
@@ -383,6 +542,10 @@ export async function createProject(payload: ProjectInput, actorId?: string | nu
|
||||
},
|
||||
});
|
||||
|
||||
await syncProjectMilestones(transaction, createdProject.id, payload.milestones ?? []);
|
||||
return createdProject;
|
||||
});
|
||||
|
||||
const project = await getProjectById(created.id);
|
||||
if (project) {
|
||||
await logAuditEvent({
|
||||
@@ -396,6 +559,7 @@ export async function createProject(payload: ProjectInput, actorId?: string | nu
|
||||
customerId: project.customerId,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
milestoneCount: project.rollups.milestoneCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -417,7 +581,9 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
|
||||
return { ok: false as const, reason: validated.reason };
|
||||
}
|
||||
|
||||
await projectModel.update({
|
||||
await prisma.$transaction(async (transaction) => {
|
||||
const transactionProjectModel = (transaction as any).project;
|
||||
await transactionProjectModel.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
@@ -436,6 +602,9 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
|
||||
},
|
||||
});
|
||||
|
||||
await syncProjectMilestones(transaction, projectId, payload.milestones ?? []);
|
||||
});
|
||||
|
||||
const project = await getProjectById(projectId);
|
||||
if (project) {
|
||||
await logAuditEvent({
|
||||
@@ -449,6 +618,7 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
|
||||
customerId: project.customerId,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
milestoneCount: project.rollups.milestoneCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export const projectStatuses = ["PLANNED", "ACTIVE", "ON_HOLD", "AT_RISK", "COMPLETE"] 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 ProjectPriority = (typeof projectPriorities)[number];
|
||||
export type ProjectMilestoneStatus = (typeof projectMilestoneStatuses)[number];
|
||||
|
||||
export interface ProjectCustomerOptionDto {
|
||||
id: string;
|
||||
@@ -43,6 +45,37 @@ export interface ProjectSummaryDto {
|
||||
ownerName: string | null;
|
||||
dueDate: string | null;
|
||||
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 {
|
||||
@@ -56,6 +89,7 @@ export interface ProjectDetailDto extends ProjectSummaryDto {
|
||||
shipmentNumber: string | null;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
milestones: ProjectMilestoneDto[];
|
||||
}
|
||||
|
||||
export interface ProjectInput {
|
||||
@@ -69,4 +103,5 @@ export interface ProjectInput {
|
||||
ownerId: string | null;
|
||||
dueDate: string | null;
|
||||
notes: string;
|
||||
milestones: ProjectMilestoneInput[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user