mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(ara-projects): workstream selection setup screen (#410)
Card grid of the seeded workstreams at /projects/[projectId]/setup/workstreams, rendered from the workstream reference table (#407) — never a hard-coded list. The screen is revisitable, not one-shot: every toggle persists straight to project_workstream, and the current selection is read back from the DB on each visit. `?from=hub` is the only difference between the first-run wizard and a later edit from the setup hub (#444) — it swaps Continue for a return to the hub. The server decides deselects. The grid always tries the plain DELETE and reacts to the 409: `requiresConfirmation` raises the confirm dialog, `blocked` (work orders exist) refuses. The dependent counts on the cards signpost only; they never gate. canManage comes from @/lib/projects/authz, so a contractor sees the setup read-only rather than a broken editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a8589759c5
commit
40bc9a6a64
3 changed files with 494 additions and 0 deletions
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
AppWindow,
|
||||
Bath,
|
||||
CheckCircle2,
|
||||
DoorOpen,
|
||||
HardHat,
|
||||
Home,
|
||||
Layers,
|
||||
Loader2,
|
||||
Lock,
|
||||
Paintbrush,
|
||||
Plus,
|
||||
Utensils,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { describeDependents } from "@/app/api/projects/[projectId]/workstreams/cascade";
|
||||
import type { WorkstreamOption } from "@/app/api/projects/[projectId]/workstreams/queries";
|
||||
|
||||
/**
|
||||
* Presentation for one workstream in the setup grid (issue #410).
|
||||
*
|
||||
* The card is a toggle: `aria-pressed` carries the selected state so the grid
|
||||
* is navigable without relying on the colour change alone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Illustration per seeded workstream. Purely decorative — the workstream list
|
||||
* itself always comes from the `workstream` reference table (#407), and an
|
||||
* unrecognised name simply falls back to the generic icon.
|
||||
*/
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
Windows: AppWindow,
|
||||
Doors: DoorOpen,
|
||||
Roofs: Home,
|
||||
Kitchens: Utensils,
|
||||
Bathrooms: Bath,
|
||||
Asbestos: HardHat,
|
||||
"Electrical heating": Zap,
|
||||
"Cyclical decorations": Paintbrush,
|
||||
};
|
||||
|
||||
export function WorkstreamCard({
|
||||
option,
|
||||
disabled,
|
||||
pending,
|
||||
onToggle,
|
||||
}: {
|
||||
option: WorkstreamOption;
|
||||
/** Read-only viewer, or another card is mid-flight. */
|
||||
disabled: boolean;
|
||||
pending: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const Icon = ICONS[option.name] ?? Layers;
|
||||
const locked = option.dependents.workOrders > 0;
|
||||
const configured = describeDependents(option.dependents);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={option.selected}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
data-testid={`workstream-card-${option.id}`}
|
||||
data-selected={option.selected}
|
||||
className={[
|
||||
"group relative flex h-[150px] flex-col rounded-lg border-2 p-4 text-left transition-all",
|
||||
option.selected
|
||||
? "border-indigo-600 bg-white shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-md",
|
||||
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="absolute right-3 top-3">
|
||||
{pending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-indigo-600" />
|
||||
) : option.selected ? (
|
||||
<CheckCircle2
|
||||
className="h-5 w-5 text-indigo-600"
|
||||
data-testid={`workstream-selected-${option.id}`}
|
||||
/>
|
||||
) : (
|
||||
<Plus className="h-5 w-5 text-gray-300 transition-colors group-hover:text-gray-500" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"mb-2 flex h-9 w-9 items-center justify-center rounded",
|
||||
option.selected
|
||||
? "bg-indigo-50 text-indigo-700"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
|
||||
<span className="text-base font-bold leading-tight text-gray-900">
|
||||
{option.name}
|
||||
</span>
|
||||
<span className="mt-1 line-clamp-2 text-xs text-gray-500">
|
||||
{option.description}
|
||||
</span>
|
||||
|
||||
<span className="mt-auto flex flex-wrap items-center gap-1.5 pt-2">
|
||||
{locked && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||
data-testid={`workstream-locked-${option.id}`}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
{option.dependents.workOrders}{" "}
|
||||
{option.dependents.workOrders === 1 ? "work order" : "work orders"}
|
||||
</span>
|
||||
)}
|
||||
{!locked && configured && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
|
||||
<AlertTriangle className="h-3 w-3 text-gray-400" />
|
||||
{configured}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ArrowRight, Info } from "lucide-react";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { ConfirmDialog } from "@/app/components/ConfirmDialog";
|
||||
import type { WorkstreamOption } from "@/app/api/projects/[projectId]/workstreams/queries";
|
||||
import { WorkstreamCard } from "./WorkstreamCard";
|
||||
|
||||
/**
|
||||
* The workstream-selection grid (issue #410).
|
||||
*
|
||||
* Every toggle persists immediately — there is no local draft to lose — so the
|
||||
* screen behaves the same whether it is step 2 of the first-run wizard or a
|
||||
* later visit from the setup hub (#444). The only difference between the two
|
||||
* is where the footer sends you.
|
||||
*
|
||||
* The server is the authority on whether a deselect may proceed: the grid
|
||||
* always tries the plain DELETE first and reacts to the 409 it gets back
|
||||
* (`requiresConfirmation` → dialog, `blocked` → refusal). The dependent counts
|
||||
* on the cards are for signposting, never for gating.
|
||||
*/
|
||||
|
||||
/** Which way the user arrived, and therefore where the footer leads. */
|
||||
export type SetupEntryMode = "wizard" | "hub";
|
||||
|
||||
export interface WorkstreamGridData {
|
||||
workstreams: WorkstreamOption[];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export function workstreamsQueryKey(projectId: string) {
|
||||
return ["projects", projectId, "workstreams"];
|
||||
}
|
||||
|
||||
type Notice = { tone: "error" | "info"; text: string };
|
||||
|
||||
type DeselectOutcome =
|
||||
| { kind: "removed" }
|
||||
| { kind: "blocked"; reason: string }
|
||||
| { kind: "needs-confirmation"; reason: string };
|
||||
|
||||
export function WorkstreamSelectionGrid({
|
||||
projectId,
|
||||
mode,
|
||||
initialData,
|
||||
}: {
|
||||
projectId: string;
|
||||
mode: SetupEntryMode;
|
||||
initialData: WorkstreamGridData;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [pendingDeselect, setPendingDeselect] = useState<{
|
||||
option: WorkstreamOption;
|
||||
reason: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: workstreamsQueryKey(projectId),
|
||||
queryFn: async (): Promise<WorkstreamGridData> => {
|
||||
const res = await fetch(`/api/projects/${projectId}/workstreams`);
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? "Couldn't load workstreams");
|
||||
return body;
|
||||
},
|
||||
initialData,
|
||||
});
|
||||
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({ queryKey: workstreamsQueryKey(projectId) });
|
||||
|
||||
const select = useMutation({
|
||||
mutationFn: async (option: WorkstreamOption) => {
|
||||
const res = await fetch(`/api/projects/${projectId}/workstreams`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ workstreamId: option.id }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error ?? "Couldn't add the workstream");
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNotice(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (err: Error) => setNotice({ tone: "error", text: err.message }),
|
||||
});
|
||||
|
||||
const deselect = useMutation({
|
||||
mutationFn: async ({
|
||||
option,
|
||||
confirmed,
|
||||
}: {
|
||||
option: WorkstreamOption;
|
||||
confirmed: boolean;
|
||||
}): Promise<DeselectOutcome> => {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/workstreams/${option.id}${
|
||||
confirmed ? "?confirm=true" : ""
|
||||
}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
// 409 is not a failure — it is the cascade rules asking a question
|
||||
// (confirm) or refusing outright (work orders exist).
|
||||
if (res.status === 409) {
|
||||
const reason = body.error ?? "This workstream can't be removed.";
|
||||
return body.blocked
|
||||
? { kind: "blocked", reason }
|
||||
: { kind: "needs-confirmation", reason };
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error ?? "Couldn't remove the workstream");
|
||||
}
|
||||
return { kind: "removed" };
|
||||
},
|
||||
onSuccess: (outcome, { option }) => {
|
||||
if (outcome.kind === "needs-confirmation") {
|
||||
setPendingDeselect({ option, reason: outcome.reason });
|
||||
return;
|
||||
}
|
||||
setPendingDeselect(null);
|
||||
setNotice(
|
||||
outcome.kind === "blocked"
|
||||
? { tone: "error", text: outcome.reason }
|
||||
: null,
|
||||
);
|
||||
// Even a refusal refreshes: the counts that caused it may have moved.
|
||||
refresh();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setPendingDeselect(null);
|
||||
setNotice({ tone: "error", text: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
const busyId = select.isLoading
|
||||
? select.variables?.id
|
||||
: deselect.isLoading
|
||||
? deselect.variables?.option.id
|
||||
: undefined;
|
||||
|
||||
const { workstreams, canManage } = data;
|
||||
const selectedCount = workstreams.filter((w) => w.selected).length;
|
||||
|
||||
function toggle(option: WorkstreamOption) {
|
||||
if (!canManage || busyId) return;
|
||||
setNotice(null);
|
||||
if (option.selected) {
|
||||
deselect.mutate({ option, confirmed: false });
|
||||
} else {
|
||||
select.mutate(option);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{!canManage && (
|
||||
<p
|
||||
className="mb-5 flex items-start gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600"
|
||||
data-testid="workstreams-read-only"
|
||||
>
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
You can see this project’s setup, but only the client
|
||||
organisation and Domna can change it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
className={[
|
||||
"mb-5 rounded-lg border px-4 py-3 text-sm",
|
||||
notice.tone === "error"
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600",
|
||||
].join(" ")}
|
||||
role="alert"
|
||||
data-testid="workstreams-notice"
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{workstreams.length === 0 ? (
|
||||
<div
|
||||
className="rounded-2xl border-2 border-dashed border-gray-200 py-16 text-center"
|
||||
data-testid="workstreams-empty"
|
||||
>
|
||||
<p className="mb-1 text-base font-semibold text-gray-700">
|
||||
No workstreams available
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
The workstream reference data hasn’t been seeded for this
|
||||
environment yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mb-8 grid flex-1 content-start grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
data-testid="workstream-grid"
|
||||
>
|
||||
{workstreams.map((option) => (
|
||||
<WorkstreamCard
|
||||
key={option.id}
|
||||
option={option}
|
||||
disabled={!canManage || (Boolean(busyId) && busyId !== option.id)}
|
||||
pending={busyId === option.id}
|
||||
onToggle={() => toggle(option)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-gray-200 pt-6">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-gray-500" data-testid="selected-count">
|
||||
{selectedCount} selected
|
||||
{canManage && " · changes save as you go"}
|
||||
</span>
|
||||
{mode === "hub" ? (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/projects/${projectId}/settings`}
|
||||
data-testid="workstreams-back-to-setup"
|
||||
>
|
||||
Back to setup
|
||||
</Link>
|
||||
</Button>
|
||||
) : selectedCount === 0 ? (
|
||||
// `asChild` renders an anchor, which ignores `disabled` — so an
|
||||
// un-followable Continue has to be a real button.
|
||||
<Button disabled data-testid="workstreams-continue">
|
||||
Continue
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/projects/${projectId}/setup/stages`}
|
||||
data-testid="workstreams-continue"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingDeselect)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDeselect(null);
|
||||
}}
|
||||
title={
|
||||
pendingDeselect
|
||||
? `Remove ${pendingDeselect.option.name}?`
|
||||
: "Remove workstream?"
|
||||
}
|
||||
description={pendingDeselect?.reason ?? ""}
|
||||
confirmLabel="Remove workstream"
|
||||
destructive
|
||||
isPending={deselect.isLoading}
|
||||
onConfirm={() => {
|
||||
if (pendingDeselect) {
|
||||
deselect.mutate({ option: pendingDeselect.option, confirmed: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/app/projects/[projectId]/setup/workstreams/page.tsx
Normal file
81
src/app/projects/[projectId]/setup/workstreams/page.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { requireProjectAccess } from "../../../authz";
|
||||
import { authorizeWorkstreamRequest } from "@/app/api/projects/[projectId]/workstreams/authorize";
|
||||
import { listWorkstreamOptions } from "@/app/api/projects/[projectId]/workstreams/queries";
|
||||
import {
|
||||
WorkstreamSelectionGrid,
|
||||
type SetupEntryMode,
|
||||
} from "./WorkstreamSelectionGrid";
|
||||
|
||||
export const metadata = {
|
||||
title: "Select workstreams | Ara",
|
||||
};
|
||||
|
||||
/**
|
||||
* Workstream selection — setup step 2 (issue #410).
|
||||
*
|
||||
* Independently addressable, and deliberately so: it is step 2 of the
|
||||
* first-run wizard *and* the screen the setup hub (#444) links to when the
|
||||
* selection needs changing later. `?from=hub` is the only difference between
|
||||
* the two — it swaps the footer's Continue for a return to the hub. Everything
|
||||
* else, including the current selection, is read back from the database on
|
||||
* every visit, so a revisit is never a blank slate.
|
||||
*
|
||||
* Adding a workstream later leaves it needing stages (#411) and a contractor
|
||||
* (#413); surfacing that is the readiness helper's job (#414), not this
|
||||
* screen's.
|
||||
*/
|
||||
export default async function SelectWorkstreamsPage(props: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
searchParams: Promise<{ from?: string }>;
|
||||
}) {
|
||||
const { projectId } = await props.params;
|
||||
const { from } = await props.searchParams;
|
||||
|
||||
// Session guard first (defence in depth behind src/middleware.ts), then the
|
||||
// real per-project rule from #408.
|
||||
await requireProjectAccess(projectId);
|
||||
const auth = await authorizeWorkstreamRequest(projectId, "view");
|
||||
if (!auth.ok) notFound();
|
||||
|
||||
const workstreams = await listWorkstreamOptions(auth.projectId);
|
||||
const mode: SetupEntryMode = from === "hub" ? "hub" : "wizard";
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-6xl flex-col px-6 py-10">
|
||||
<div className="mb-8 max-w-3xl">
|
||||
{mode === "wizard" ? (
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Step 2 of 5
|
||||
</span>
|
||||
<span className="h-1 w-full max-w-[200px] overflow-hidden rounded-full bg-gray-100">
|
||||
<span className="block h-full w-2/5 rounded-full bg-indigo-600" />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Project setup
|
||||
</p>
|
||||
)}
|
||||
<h1
|
||||
className="text-3xl font-extrabold tracking-tight text-gray-900"
|
||||
data-testid="workstreams-heading"
|
||||
>
|
||||
Select workstreams
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Choose the categories of work this project delivers. Each one gets its
|
||||
own stages, evidence requirements and contractors — you can change the
|
||||
selection later from project setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<WorkstreamSelectionGrid
|
||||
projectId={projectId}
|
||||
mode={mode}
|
||||
initialData={{ workstreams, canManage: auth.canManage }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue