diff --git a/src/app/projects/[projectId]/setup/workstreams/WorkstreamCard.tsx b/src/app/projects/[projectId]/setup/workstreams/WorkstreamCard.tsx new file mode 100644 index 00000000..22785df6 --- /dev/null +++ b/src/app/projects/[projectId]/setup/workstreams/WorkstreamCard.tsx @@ -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 = { + 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 ( + + ); +} diff --git a/src/app/projects/[projectId]/setup/workstreams/WorkstreamSelectionGrid.tsx b/src/app/projects/[projectId]/setup/workstreams/WorkstreamSelectionGrid.tsx new file mode 100644 index 00000000..a736db01 --- /dev/null +++ b/src/app/projects/[projectId]/setup/workstreams/WorkstreamSelectionGrid.tsx @@ -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(null); + const [pendingDeselect, setPendingDeselect] = useState<{ + option: WorkstreamOption; + reason: string; + } | null>(null); + + const { data } = useQuery({ + queryKey: workstreamsQueryKey(projectId), + queryFn: async (): Promise => { + 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 => { + 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 ( +
+ {!canManage && ( +

+ + You can see this project’s setup, but only the client + organisation and Domna can change it. +

+ )} + + {notice && ( +

+ {notice.text} +

+ )} + + {workstreams.length === 0 ? ( +
+

+ No workstreams available +

+

+ The workstream reference data hasn’t been seeded for this + environment yet. +

+
+ ) : ( +
+ {workstreams.map((option) => ( + toggle(option)} + /> + ))} +
+ )} + +
+ + +
+ + {selectedCount} selected + {canManage && " · changes save as you go"} + + {mode === "hub" ? ( + + ) : selectedCount === 0 ? ( + // `asChild` renders an anchor, which ignores `disabled` — so an + // un-followable Continue has to be a real button. + + ) : ( + + )} +
+
+ + { + 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 }); + } + }} + /> +
+ ); +} diff --git a/src/app/projects/[projectId]/setup/workstreams/page.tsx b/src/app/projects/[projectId]/setup/workstreams/page.tsx new file mode 100644 index 00000000..1e9447d7 --- /dev/null +++ b/src/app/projects/[projectId]/setup/workstreams/page.tsx @@ -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 ( +
+
+ {mode === "wizard" ? ( +
+ + Step 2 of 5 + + + + +
+ ) : ( +

+ Project setup +

+ )} +

+ Select workstreams +

+

+ 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. +

+
+ + +
+ ); +}