From 3b84ed58ada5f45e1289afe5d8feccf55a045ddf Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 10:22:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(ara-projects):=20import=20mapping=20screen?= =?UTF-8?q?=20=E2=80=94=20column=20+=20value=20mapping=20UI=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of 3's screen, on top of the pure logic in the previous commit. Follows wireframes 08 (value mapping locked) and 09 (unlocked). Writes nothing. Where the step lives Client state inside ImportUpload, not a /import/mapping route: the parsed rows live in that component (#414), so a URL step would drop them and force a re-upload on every Back. "Continue to mapping" — disabled since #414 — now opens the mapping screen. ImportMapping — the screen Owns the two mapping layers and nothing else. Statuses, counts, which values need review and whether Continue is enabled are all derived inline from (file, columnMapping, valueMapping, targets) on each render, so re-pointing the workstream column immediately re-groups the contractor and stage tables — they were never a separate copy of the answer. No useEffect, no useMemo. ColumnMappingTable — file column -> Ara field One row per file column with the wireframe's matched / needs-review affordances, an "e.g. …" sample from the preview, and a shadcn Select per row offering every field plus "Don't import this column". Picking a field another column already holds says so in the option hint rather than moving it silently. Beneath the table, the requirement blockers: which required fields are still unmapped and why. Unmapped *optional* fields are stated, never blocking. ValueMappingTables — values -> the project's own entities Three tables. Workstream values map flat; contractor and stage values are grouped under the workstream they are scoped to, because a contractor assignment and a stage ladder belong to a workstream (ADR-0019) — the same "Acme" under two workstreams is two questions, and each picker offers only that workstream's assignments. Row counts per value, auto-matched marked as such. Notes carry the cases with no row of their own: rows waiting on their workstream mapping, blank contractors (an error downstream), and blank stages (not an error — they start at the workstream's first stage). Gating (AC) The value tables are locked, exactly as wireframe 08 shows, until every required column is mapped — there is nothing to list until we know which column holds the workstream. Continue to validation is disabled until the same condition holds, and names what is missing. Unmapped *values* never block: they surface as "N of M rows will fail validation as mapped". page.tsx Loads the value-mapping targets server-side alongside readiness (read-only, and skipped entirely for a gated project, which has nothing to map to). Container widened to max-w-5xl for the mapping tables; the eyebrow now covers steps 1–2. ADR-0020 — records that the import session, and the mapping persisted alongside it, are client-held. Persisting the mapping without the rows buys nothing: the rows are client-held too, so a refresh loses them either way and a restored mapping has no file to apply to. ImportSession is JSON-clean so a future server-side session can store it unchanged; the ADR names moving the file server-side as the thing that would change the answer. It also records the production-database constraint honestly, as a constraint and not as the reason. Stepping back to the drop zone and forward again keeps the mapping: the session is held above the mapping component, so that component may unmount freely. 225 tests pass across src/lib/projects; tsc and eslint clean. No component tests — this repo runs vitest in a node environment with no DOM setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ort-session-and-mapping-are-client-held.md | 91 ++++ .../import/components/ColumnMappingTable.tsx | 176 ++++++++ .../import/components/ImportMapping.tsx | 242 +++++++++++ .../import/components/ImportUpload.tsx | 135 +++++- .../import/components/MappingSelect.tsx | 126 ++++++ .../import/components/ValueMappingTables.tsx | 390 ++++++++++++++++++ src/app/projects/[projectId]/import/page.tsx | 30 +- 7 files changed, 1178 insertions(+), 12 deletions(-) create mode 100644 docs/adr/0020-import-session-and-mapping-are-client-held.md create mode 100644 src/app/projects/[projectId]/import/components/ColumnMappingTable.tsx create mode 100644 src/app/projects/[projectId]/import/components/ImportMapping.tsx create mode 100644 src/app/projects/[projectId]/import/components/MappingSelect.tsx create mode 100644 src/app/projects/[projectId]/import/components/ValueMappingTables.tsx diff --git a/docs/adr/0020-import-session-and-mapping-are-client-held.md b/docs/adr/0020-import-session-and-mapping-are-client-held.md new file mode 100644 index 00000000..46224315 --- /dev/null +++ b/docs/adr/0020-import-session-and-mapping-are-client-held.md @@ -0,0 +1,91 @@ +# 20. The work-order import session — and the mapping persisted alongside it — is client-held + +Date: 2026-07-23 + +## Status + +Accepted + +Numbering note: two ADRs carry 0019 (the UK date format one, and the +work-order-import one from #414's branch). 0020 follows both. + +## Context + +Issue #415 (import step 2 of 3) asks for two mapping layers over the parsed +file — column mapping and value mapping — and for the result to be +"persist[ed] alongside the import session". + +The import journey has no session row. #414 decided that the parse route holds +nothing: it reads the uploaded file, returns **every** row to the browser, and +writes nothing. The mapping, validation and commit steps all read that array +from the import screen's React state, and commit (#417) is the first and only +write. There is no `import_session` table, no staged file in S3, and no +`work_order` row until the user commits. + +So "alongside the import session" had to be given a meaning. Three options: + +1. **A database session.** Add `import_session` (file identity, status, a JSONB + mapping), write it when the file is parsed, PATCH it as the user maps. +2. **Browser storage.** Keep the session in React state and mirror it to + `sessionStorage` so a refresh can restore it. +3. **A client-held session object.** Define `ImportSession` — file identity plus + both mapping layers — hold it in the import screen's state next to the parsed + rows, and hand it to commit. + +## Decision + +**Option 3.** `src/lib/projects/import/session.ts` defines `ImportSession`; the +import screen owns one, the mapping component edits it, and #417 will POST it +with the rows at commit. + +The shape is deliberately **JSON-clean end to end** — plain objects, string ids +(every `bigserial` is stringified at `mappingTargets`), no `bigint`, no `Date`, +no class instances. It can be POSTed verbatim and stored in a JSONB column +unchanged, which is how `bulk_address_uploads.column_mapping` already holds the +older BulkUpload journey's equivalent. + +`sessionMatchesFile` guards resumption: a mapping is a set of **column +indices**, so a session is meaningless against a file whose headings differ, and +restoring one would silently map the wrong columns. + +### Why not the database session + +Persisting the mapping without the rows buys the user nothing. The rows are +client-held, so a refresh loses them regardless; the user must re-upload, and a +restored mapping cannot be applied to a file that is no longer in hand. A +session row would therefore be durable state that no journey can currently +resume from — and it would cost a migration, a status lifecycle, a PATCH +endpoint and an expiry story to earn it. + +There is also a constraint worth recording plainly: this branch works against a +shared production database and was scoped to write nothing — no migrations. That +ruled option 1 out for *this* ticket, but it is not the reason it was rejected; +the reason is that the rows and the mapping have to be durable together or +neither is useful. + +### Why not `sessionStorage` + +It survives a refresh, but only for the mapping — the rows still do not, so the +restored mapping has no file to apply to. It also has to be read at mount, which +in a server-rendered client component means either a hydration mismatch or an +effect, and this codebase avoids `useEffect` by convention. + +## Consequences + +- A page refresh loses the import in progress, and the user re-uploads. This is + unchanged from #414, and the mapping does not make it worse. +- Stepping back to the drop zone and forward again does **not** lose the + mapping: the session lives in `ImportUpload`, above the mapping component, so + the mapping component may unmount freely. This is why every edit reports the + whole session upward rather than only living in the table's own state. +- No server-side record exists of who mapped what, or of a mapping that was + never committed. Nothing in v1 asks for one; if audit becomes a requirement, + it lands with the session row, not before. +- Two mapping layers on 20,000 rows are re-derived on every edit rather than + cached. That is affordable (a few passes over three cells per row) and is what + keeps the tables from disagreeing with the mapping the user is editing. +- **What would change the answer:** moving the file server-side. If the parsed + rows are ever staged (S3, or a rows table) — which is the natural fix for + files too large to hold in the browser, and the shape the BulkUpload journey + already uses — then a session row becomes worth its cost, and `ImportSession` + is already the payload it would store. diff --git a/src/app/projects/[projectId]/import/components/ColumnMappingTable.tsx b/src/app/projects/[projectId]/import/components/ColumnMappingTable.tsx new file mode 100644 index 00000000..0a24b358 --- /dev/null +++ b/src/app/projects/[projectId]/import/components/ColumnMappingTable.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { + assignColumn, + columnMappingRows, + IMPORT_FIELDS, + IMPORT_FIELDS_BY_KEY, + IMPORT_REQUIREMENTS, + summariseColumnMapping, + type ImportColumnMapping, + type ImportFieldKey, +} from "@/lib/projects/import/columnMapping"; +import { MappingSelect, MappingStatusIcon } from "./MappingSelect"; + +/** Fields named by a requirement carry the "required" tag in the picker. */ +const REQUIRED_KEYS = new Set( + IMPORT_REQUIREMENTS.flatMap((requirement) => requirement.keys), +); + +/** + * Layer 1 of the mapping step (issue #415): one row per **file column**, each + * pointed at an Ara field, at "don't import", or at nothing yet. + * + * Rows are file columns rather than Ara fields because that is the direction + * the wireframe reads (file → Ara) and the direction the user thinks in — they + * are looking at their own spreadsheet. Which *fields* are still missing is a + * separate question, answered by the blocker list beneath the table, since a + * missing field has no row of its own to complain from. + * + * Stateless: it renders the mapping it is handed and reports edits upward, so + * the whole screen re-derives from one value. + */ +export function ColumnMappingTable({ + headers, + preview, + mapping, + onChange, +}: { + headers: string[]; + /** Sample rows, for the "e.g. …" hint under each heading. */ + preview: string[][]; + mapping: ImportColumnMapping; + onChange: (next: ImportColumnMapping) => void; +}) { + const rows = columnMappingRows(headers, mapping, preview); + const summary = summariseColumnMapping(headers, mapping); + + return ( +
+
+
+

Column mapping

+

+ Match each column in your file to the field it holds. +

+
+ + File → Ara + +
+ +
+ + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
File columnStatusAra field
+
{row.header}
+ {row.sample !== "" && ( +
+ e.g. {row.sample} +
+ )} +
+ + + + onChange( + assignColumn( + mapping, + row.columnIndex, + next === "__ignore__" + ? "ignore" + : (next as ImportFieldKey | null), + ), + ) + } + options={[ + ...IMPORT_FIELDS.map((field) => { + const heldBy = mapping.fields[field.key]; + return { + value: field.key, + label: REQUIRED_KEYS.has(field.key) + ? `${field.label} (required)` + : field.label, + // Say so when picking this field would take it off + // another column, rather than silently moving it. + hint: + heldBy !== undefined && heldBy !== row.columnIndex + ? `currently ${headers[heldBy]}` + : field.hint, + }; + }), + { value: "__ignore__", label: "Don't import this column" }, + ]} + /> +
+
+ + {summary.missingRequirements.length > 0 && ( +
+

+ {summary.missingRequirements.length} required field + {summary.missingRequirements.length === 1 ? "" : "s"} still to map +

+
    + {summary.missingRequirements.map((requirement) => ( +
  • + {requirement.label} —{" "} + {requirement.reason} +
  • + ))} +
+
+ )} + + {summary.complete && summary.unmappedOptionalFields.length > 0 && ( +
+

+ Not mapped (optional):{" "} + {summary.unmappedOptionalFields + .map((key) => IMPORT_FIELDS_BY_KEY[key].label) + .join(", ")} + . +

+
+ )} +
+ ); +} diff --git a/src/app/projects/[projectId]/import/components/ImportMapping.tsx b/src/app/projects/[projectId]/import/components/ImportMapping.tsx new file mode 100644 index 00000000..f0fbeb17 --- /dev/null +++ b/src/app/projects/[projectId]/import/components/ImportMapping.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState } from "react"; +import { + ArrowLeftIcon, + DocumentTextIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { + autoMatchColumns, + summariseColumnMapping, +} from "@/lib/projects/import/columnMapping"; +import type { ImportMappingTargets } from "@/lib/projects/import/mappingTargets"; +import { + buildImportSession, + sessionMatchesFile, + type ImportSession, +} from "@/lib/projects/import/session"; +import { + buildValueMappingModel, + EMPTY_VALUE_MAPPING, +} from "@/lib/projects/import/valueMapping"; +import { ColumnMappingTable } from "./ColumnMappingTable"; +import { ValueMappingTables } from "./ValueMappingTables"; +import type { ParsedImportResponse } from "./ImportUpload"; + +/** What the mapping step hands to validation (#417) when the user continues. */ +export interface MappingHandoff { + session: ImportSession; + /** Rows that will fail validation as mapped — a warning, never a blocker. */ + warningRows: number; +} + +/** + * Work-order import, step 2 of 3 — column mapping and value mapping (#415). + * + * Wireframes: `08-map-columns-rebuilt.html` (value mapping locked) and + * `09-map-columns-workstream-unlocked.html` (unlocked). + * + * Holds the two mapping layers and nothing else. Every figure on the screen — + * statuses, counts, which values need review, whether Continue is enabled — is + * **derived inline** from `(file, columnMapping, valueMapping, targets)` on each + * render rather than stored, so the tables cannot disagree with the mapping the + * user is looking at. That is also why re-pointing the workstream column + * immediately re-groups the contractor and stage tables: they were never a + * separate copy of the answer. + * + * The initial column mapping is auto-matched once, in the `useState` + * initialiser — unless a session for this same file comes back in, which is + * what returning from a later step restores. It is a *suggestion*, so it + * belongs in state (the user edits it) rather than being recomputed each render. + * + * Every edit also reports the whole session upward. That is what "persist the + * mapping alongside the import session" amounts to while the session is + * client-held (ADR-0020): the parent owns the session, this component owns the + * editing of it, and stepping away and back does not lose the work. + */ +export function ImportMapping({ + file, + targets, + initialSession, + onSessionChange, + onBack, + onContinue, +}: { + file: ParsedImportResponse; + targets: ImportMappingTargets; + /** A previously-edited session to resume; ignored unless it fits this file. */ + initialSession?: ImportSession | null; + /** Called with the full session after every edit, so the parent can keep it. */ + onSessionChange?: (session: ImportSession) => void; + onBack: () => void; + onContinue: (handoff: MappingHandoff) => void; +}) { + // A mapping is a set of column indices, so a session from a *different* file + // would map the wrong columns; `sessionMatchesFile` is what refuses it. + const resumed = + initialSession && sessionMatchesFile(initialSession, file) + ? initialSession + : null; + + const [columnMapping, setColumnMapping] = useState( + () => resumed?.columnMapping ?? autoMatchColumns(file.headers), + ); + const [valueMapping, setValueMapping] = useState( + () => resumed?.valueMapping ?? EMPTY_VALUE_MAPPING, + ); + + function updateColumns(next: typeof columnMapping) { + setColumnMapping(next); + onSessionChange?.(buildImportSession(file, next, valueMapping)); + } + + function updateValues(next: typeof valueMapping) { + setValueMapping(next); + onSessionChange?.(buildImportSession(file, columnMapping, next)); + } + + const summary = summariseColumnMapping(file.headers, columnMapping); + const valueModel = buildValueMappingModel( + file.rows, + columnMapping, + valueMapping, + targets, + ); + + const valuesNeedingReview = + valueModel.needsReview.workstreams + + valueModel.needsReview.contractors + + valueModel.needsReview.stages; + + return ( +
+
+
+ +
+

+ {file.filename} +

+

+ {file.rowCount.toLocaleString("en-GB")} row + {file.rowCount === 1 ? "" : "s"} · {file.headers.length} column + {file.headers.length === 1 ? "" : "s"} +

+
+
+
+
+
0 ? "warning" : "plain" + } + /> +
+
+ +
+ +
+ + + + {summary.complete && valueModel.warningRows > 0 && ( +
+ +
+

+ {valueModel.warningRows.toLocaleString("en-GB")} of{" "} + {file.rowCount.toLocaleString("en-GB")} rows will fail validation + as mapped +

+

+ Rows with an unmapped workstream, a missing or unmapped contractor, + or a stage that matches nothing cannot be imported. You can + continue and deal with them at validation, or map the values above + first. +

+
+
+ )} + +
+ + +
+ {!summary.complete && ( +

+ Map{" "} + {summary.missingRequirements.map((r) => r.label).join(", ")} to + continue. +

+ )} + +
+
+
+ ); +} + +/** One figure in the file summary card. */ +function Figure({ + label, + value, + tone = "plain", +}: { + label: string; + value: number; + tone?: "plain" | "warning"; +}) { + return ( +
+

+ {label} +

+

+ {value.toLocaleString("en-GB")} +

+
+ ); +} diff --git a/src/app/projects/[projectId]/import/components/ImportUpload.tsx b/src/app/projects/[projectId]/import/components/ImportUpload.tsx index e8857d93..532e0867 100644 --- a/src/app/projects/[projectId]/import/components/ImportUpload.tsx +++ b/src/app/projects/[projectId]/import/components/ImportUpload.tsx @@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query"; import { ArrowDownTrayIcon, ArrowPathIcon, + CheckCircleIcon, CloudArrowUpIcon, DocumentTextIcon, ExclamationTriangleIcon, @@ -15,6 +16,9 @@ import { hasAcceptedImportExtension, MAX_IMPORT_FILE_BYTES, } from "@/lib/projects/import/parse"; +import type { ImportMappingTargets } from "@/lib/projects/import/mappingTargets"; +import type { ImportSession } from "@/lib/projects/import/session"; +import { ImportMapping, type MappingHandoff } from "./ImportMapping"; /** The parse route's response — the whole file, not just the preview. */ export interface ParsedImportResponse { @@ -27,21 +31,44 @@ export interface ParsedImportResponse { const MAX_MB = MAX_IMPORT_FILE_BYTES / (1024 * 1024); +/** Which of the wizard's steps this component is showing. */ +type ImportStep = "upload" | "mapping" | "mapped"; + /** - * Work-order import drop zone (issue #414). + * Work-order import drop zone (issue #414) and the step-2 mapping screen it + * hands off to (issue #415). * * Uploads the file to the parse route and renders the returned headers and * preview. The full `rows` array stays here in `parse.data` — the mapping * (#415), validation (#416) and commit (#417) steps read it from this * component's state rather than re-fetching or re-parsing, which is why the * route hands back every row and not only the preview. + * + * The step is client state rather than a route because the parsed file lives + * here: navigating to a `/import/mapping` URL would drop the rows on the floor + * and force a re-upload on every Back. Going back to the upload step therefore + * keeps the parsed file, and only choosing a different file discards it. */ -export function ImportUpload({ projectId }: { projectId: string }) { +export function ImportUpload({ + projectId, + targets, +}: { + projectId: string; + /** The project's workstreams, stages and contractors — the value-mapping targets. */ + targets: ImportMappingTargets; +}) { const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [fileName, setFileName] = useState(null); // Size/extension failures caught before the upload; the route re-checks both. const [clientError, setClientError] = useState(null); + const [step, setStep] = useState("upload"); + // The import session: the file's mapping, held here so stepping back to the + // drop zone and forward again does not throw the user's work away. Nothing is + // written server-side until commit (#417) — see ADR-0020. + const [session, setSession] = useState(null); + // Set when the user finishes mapping — the artefact validation (#417) takes. + const [handoff, setHandoff] = useState(null); const parse = useMutation({ mutationFn: async (file: File) => { @@ -61,6 +88,11 @@ export function ImportUpload({ projectId }: { projectId: string }) { setClientError(null); parse.reset(); setFileName(file.name); + // A different file means a different set of columns, so any mapping made + // against the old one is meaningless (see `sessionMatchesFile`). + setStep("upload"); + setSession(null); + setHandoff(null); if (!hasAcceptedImportExtension(file.name)) { setClientError( @@ -93,12 +125,44 @@ export function ImportUpload({ projectId }: { projectId: string }) { setFileName(null); setClientError(null); parse.reset(); + setStep("upload"); + setSession(null); + setHandoff(null); if (fileInputRef.current) fileInputRef.current.value = ""; } const error = clientError ?? (parse.isError ? parse.error.message : null); const parsed = parse.data; + if (parsed && step === "mapping") { + return ( + setStep("upload")} + onContinue={(next) => { + setHandoff(next); + setStep("mapped"); + }} + /> + ); + } + + if (parsed && step === "mapped" && handoff) { + return ( + setStep("mapping")} + onStartOver={reset} + /> + ); + } + return (
@@ -251,3 +317,64 @@ export function ImportUpload({ projectId }: { projectId: string }) { ); } + +/** + * The end of step 2: the mapping is settled and held in the import session, + * waiting for validation and commit (#417) to consume it. + * + * It states what was captured rather than claiming anything was saved to the + * server, because nothing was — an import writes nothing until commit + * (ADR-0020). Both ways back are offered: edit the mapping, or start again with + * a different file. + */ +function MappingSaved({ + handoff, + onEdit, + onStartOver, +}: { + handoff: MappingHandoff; + onEdit: () => void; + onStartOver: () => void; +}) { + const { session, warningRows } = handoff; + const mappedFields = Object.keys(session.columnMapping.fields).length; + + return ( +
+
+ +
+

+ Mapping complete +

+

+ {mappedFields} field{mappedFields === 1 ? "" : "s"} mapped across{" "} + {session.rowCount.toLocaleString("en-GB")} row + {session.rowCount === 1 ? "" : "s"} of {session.filename}. Nothing + has been written yet — validation and commit arrive in #417. +

+ + {warningRows > 0 && ( +

+ {warningRows.toLocaleString("en-GB")} row + {warningRows === 1 ? "" : "s"} still carry values that map to + nothing and will fail validation. +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/projects/[projectId]/import/components/MappingSelect.tsx b/src/app/projects/[projectId]/import/components/MappingSelect.tsx new file mode 100644 index 00000000..f72f0668 --- /dev/null +++ b/src/app/projects/[projectId]/import/components/MappingSelect.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/app/shadcn_components/ui/select"; + +/** + * The one picker both mapping layers use (issue #415): "this file thing is that + * Ara thing", with an explicit way to say *neither*. + * + * Radix reserves the empty string as a value, so "not mapped" travels as a + * sentinel and is translated at this boundary — callers only ever see + * `string | null`, which is what the mapping modules store. + */ +const NONE = "__none__"; + +export interface MappingOption { + value: string; + label: string; + /** Muted second line — what a column is currently used for, a contractor's org, … */ + hint?: string; +} + +export function MappingSelect({ + value, + options, + onChange, + noneLabel, + placeholder, + disabled = false, + invalid = false, + testId, + ariaLabel, +}: { + /** The chosen option's value, or null when nothing is mapped. */ + value: string | null; + options: MappingOption[]; + /** Called with null when the user picks the "none" entry. */ + onChange: (value: string | null) => void; + /** What choosing nothing means here — "Don't import", "Leave unmapped", … */ + noneLabel: string; + placeholder: string; + disabled?: boolean; + /** Draws the needs-review treatment the wireframe gives unmapped rows. */ + invalid?: boolean; + testId?: string; + ariaLabel: string; +}) { + return ( + + ); +} + +/** The wireframe's per-row status affordance, shared by both tables. */ +export function MappingStatusIcon({ + status, +}: { + status: "matched" | "needs_review" | "ignored"; +}) { + if (status === "matched") { + return ( + + ✓ + + ); + } + if (status === "ignored") { + return ( + + – + + ); + } + return ( + + ? + + ); +} diff --git a/src/app/projects/[projectId]/import/components/ValueMappingTables.tsx b/src/app/projects/[projectId]/import/components/ValueMappingTables.tsx new file mode 100644 index 00000000..95e499c0 --- /dev/null +++ b/src/app/projects/[projectId]/import/components/ValueMappingTables.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { LockClosedIcon } from "@heroicons/react/24/outline"; +import type { ImportMappingTargets } from "@/lib/projects/import/mappingTargets"; +import { + setContractorValue, + setStageValue, + setWorkstreamValue, + type ImportValueMapping, + type ScopedValueEntry, + type ValueMappingModel, +} from "@/lib/projects/import/valueMapping"; +import { MappingSelect, MappingStatusIcon } from "./MappingSelect"; + +/** + * Layer 2 of the mapping step (issue #415): the file's *values* against the + * project's own workstreams, contractors and stages. + * + * Three tables rather than one, because they answer different questions at + * different scopes — and the two lower ones are scoped **by** the first: + * contractors and stages are offered per workstream, since an assignment + * belongs to a workstream and a ladder belongs to a workstream (ADR-0019). + * That is why they are grouped under workstream headings rather than listed + * flat: the same contractor name under two workstreams is two questions. + * + * Locked until column mapping is complete, exactly as wireframe 08 shows — + * there is nothing to list until we know which column holds the workstream. + */ +export function ValueMappingTables({ + model, + targets, + valueMapping, + onChange, + locked, +}: { + model: ValueMappingModel; + targets: ImportMappingTargets; + valueMapping: ImportValueMapping; + onChange: (next: ImportValueMapping) => void; + locked: boolean; +}) { + const contractorOptions = (entry: ScopedValueEntry) => + (targets.workstreams.find((w) => w.id === entry.workstreamId)?.contractors ?? []) + .map((contractor) => ({ value: contractor.id, label: contractor.name })); + + const stageOptions = (entry: ScopedValueEntry) => + (targets.workstreams.find((w) => w.id === entry.workstreamId)?.stages ?? []).map( + (stage, index) => ({ + value: stage.id, + label: stage.name, + hint: index === 0 ? "first stage" : undefined, + }), + ); + + return ( +
+ {locked && ( +
+
+ +

+ Complete column mapping to map cell values +

+

+ We can only list the values in your file once we know which column + each field comes from. +

+
+
+ )} + +
+ 0 + ? `${rows(model.blankRows.workstream)} have no workstream — those rows will fail validation.` + : null, + ]} + > + {model.workstreams.map((entry) => ( + + + onChange(setWorkstreamValue(valueMapping, entry.key, next)) + } + options={targets.workstreams.map((workstream) => ({ + value: workstream.id, + label: workstream.name, + }))} + /> + + ))} + + + + onChange(setContractorValue(valueMapping, entry.key, next)) + } + locked={locked} + empty={ + model.hasContractorColumn + ? "No contractor values to map yet." + : "Map the contractor column first." + } + notes={[ + model.blankRows.contractor > 0 + ? `${rows(model.blankRows.contractor)} have no contractor — those rows will fail validation.` + : null, + model.unscopedRows > 0 + ? `${rows(model.unscopedRows)} are waiting on their workstream value — map it above and their contractors appear here.` + : null, + ]} + /> + + + onChange(setStageValue(valueMapping, entry.key, next)) + } + locked={locked} + empty={ + model.hasStageColumn + ? "No stage values in the file — every row will start at its workstream's first stage." + : "No stage column mapped — every row will start at its workstream's first stage." + } + notes={[ + model.blankRows.stage > 0 + ? `${rows(model.blankRows.stage)} have no stage — they will start at their workstream's first stage.` + : null, + model.needsReview.stages > 0 + ? "A stage that matches nothing is an error — leave the cell blank instead if the work has not started." + : null, + ]} + /> +
+
+ ); +} + +/** "1 row" / "1,248 rows", en-GB grouped. */ +function rows(count: number): string { + return `${count.toLocaleString("en-GB")} row${count === 1 ? "" : "s"}`; +} + +/** The shared chrome of a value table: header, column headings, notes. */ +function ValuePanel({ + title, + subtitle, + testId, + valueHeader = "File value", + targetHeader, + notes, + empty, + children, +}: { + title: string; + subtitle: string; + testId: string; + valueHeader?: string; + targetHeader: string; + notes: Array; + empty: string; + children: React.ReactNode; +}) { + const hasRows = Array.isArray(children) ? children.length > 0 : Boolean(children); + const shown = notes.filter((note): note is string => note !== null); + + return ( +
+
+

{title}

+

{subtitle}

+
+ + {hasRows ? ( +
+ + + + + + + + + {children} +
{valueHeader}Status{targetHeader}
+
+ ) : ( +

{empty}

+ )} + + {shown.length > 0 && ( +
+ {shown.map((note) => ( +

+ {note} +

+ ))} +
+ )} +
+ ); +} + +/** One value row: the file's text, its row count, its status, its picker. */ +function ValueRow({ + value, + rowCount, + status, + matchedBy, + scope, + children, +}: { + value: string; + rowCount: number; + status: "matched" | "needs_review"; + matchedBy: "user" | "auto" | null; + /** The workstream a contractor/stage value sits under, when it has one. */ + scope?: string; + children: React.ReactNode; +}) { + return ( + + + + {value} + +
+ {rows(rowCount)} + {scope && · {scope}} + {matchedBy === "auto" && ( + · auto-matched + )} +
+ + + + + {children} + + ); +} + +/** + * A contractor or stage table: the same rows, grouped under the workstream they + * are scoped to, so it is never ambiguous *which* "Acme" is being mapped. + */ +function ScopedPanel({ + title, + subtitle, + testId, + targetHeader, + entries, + targets, + optionsFor, + onSelect, + placeholder, + noneTargetsNote, + locked, + notes, + empty, +}: { + title: string; + subtitle: string; + testId: string; + targetHeader: string; + entries: ScopedValueEntry[]; + targets: ImportMappingTargets; + optionsFor: (entry: ScopedValueEntry) => Array<{ + value: string; + label: string; + hint?: string; + }>; + onSelect: (entry: ScopedValueEntry, next: string | null) => void; + placeholder: string; + noneTargetsNote: string; + locked: boolean; + notes: Array; + empty: string; +}) { + // Grouped in the targets' own (alphabetical) order so the grouping does not + // reshuffle as values are mapped; within a group the biggest value leads. + const groups = targets.workstreams + .map((workstream) => ({ + workstream, + entries: entries.filter((entry) => entry.workstreamId === workstream.id), + })) + .filter((group) => group.entries.length > 0); + + return ( + + {groups.flatMap((group) => [ + + + {group.workstream.name} + {group.entries[0].noTargets && ( + + {noneTargetsNote} + + )} + + , + ...group.entries.map((entry) => ( + + onSelect(entry, next)} + options={optionsFor(entry)} + /> + + )), + ])} + + ); +} diff --git a/src/app/projects/[projectId]/import/page.tsx b/src/app/projects/[projectId]/import/page.tsx index 8831674f..b3368ef0 100644 --- a/src/app/projects/[projectId]/import/page.tsx +++ b/src/app/projects/[projectId]/import/page.tsx @@ -2,6 +2,10 @@ import { notFound } from "next/navigation"; import { requireProjectAccess } from "../../guards"; import { canManageProject } from "@/lib/projects/authz"; import { loadImportReadiness } from "@/lib/projects/import/readiness"; +import { + EMPTY_MAPPING_TARGETS, + loadMappingTargets, +} from "@/lib/projects/import/mappingTargets"; import { ImportUpload } from "./components/ImportUpload"; import { ImportSetupIncomplete } from "./components/ImportSetupIncomplete"; @@ -10,11 +14,14 @@ export const metadata = { }; /** - * Work-order import, step 1 of 3 — upload and parse (issue #414). + * Work-order import, steps 1 and 2 of 3 — upload and parse (issue #414), then + * column mapping and value mapping (issue #415). * - * Wireframe: `docs/wireframes/ara-projects/1-admin-import/07-import-upload.html`. - * The mapping step (#415), validation and commit (#417) follow; this page ends - * at a parsed preview and writes nothing. + * Wireframes: `07-import-upload.html`, then `08-map-columns-rebuilt.html` and + * `09-map-columns-workstream-unlocked.html`. Both steps live behind this one + * route because the parsed file is held client-side (ADR-0020) — a separate + * mapping URL would drop it. Validation and commit (#417) follow; this page + * still writes nothing. * * Only internal and client-org managers may import, so this checks * `canManageProject` rather than settling for the layout's view-level guard — @@ -52,14 +59,21 @@ export default async function ImportPage(props: { // zone at all. 404 rather than 403, so project existence stays private. if (!canManageProject(user, project)) notFound(); - // Read-only: gather whether the project is set up enough to import against. + // Read-only: gather whether the project is set up enough to import against, + // and — when it is — what the file's values may be mapped to. The targets are + // loaded here, on the server, rather than fetched by the mapping step: they + // are the project's own configuration, they cannot change mid-import, and a + // gated project has nothing to map to anyway. const readiness = await loadImportReadiness(project.id); + const targets = readiness.ready + ? await loadMappingTargets(project.id) + : EMPTY_MAPPING_TARGETS; return ( -
+

- Work-order import · Step 1 of 3 + Work-order import · Steps 1–2 of 3

{readiness.ready ? ( - + ) : (