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

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