projects milestones
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user