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 ? ( - + ) : (