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

@@ -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>

View File

@@ -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">

View File

@@ -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>

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 }> = [
{ 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: [],
};