diff --git a/frontend/src/productsApi.ts b/frontend/src/productsApi.ts index 486ec2e..ae039fe 100644 --- a/frontend/src/productsApi.ts +++ b/frontend/src/productsApi.ts @@ -42,6 +42,11 @@ export interface ProductsSummary { export type Result = { ok: true; value: T } | { ok: false; error: ApiError; status: number }; +// One label rule for CSV dialects, shared by Products history / preview / run detail. +export function dialectLabel(d: string): string { + return d === "canonical" ? "Canonical format" : d; +} + async function request(path: string, init?: RequestInit): Promise> { const resp = await fetch(path, { credentials: "include", ...init }); if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status }; diff --git a/frontend/src/screens/products/ImportPreview.tsx b/frontend/src/screens/products/ImportPreview.tsx index 65945fd..6b63d0d 100644 --- a/frontend/src/screens/products/ImportPreview.tsx +++ b/frontend/src/screens/products/ImportPreview.tsx @@ -1,10 +1,11 @@ // Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a // drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a). // Diff glyphs pair with color, never color alone (§6.6). -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { cancelDraft, confirmDraft, + dialectLabel, getDraft, getDraftRecords, type Draft, @@ -40,8 +41,8 @@ function SetLine({ field, value }: { field: string; value: unknown }) { function ChangeRow({ change }: { change: FieldChange }) { return (
- {change.field}: {fmt(change.before)} →{" "} - {fmt(change.after)} + {change.field}: − {fmt(change.before)} →{" "} + + {fmt(change.after)}
); } @@ -137,6 +138,7 @@ export default function ImportPreview({ draftId }: { draftId: number }) { const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null); const [filter, setFilter] = useState(null); const [records, setRecords] = useState(null); + const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null); const [hasMore, setHasMore] = useState(false); const [moreBusy, setMoreBusy] = useState(false); const [confirming, setConfirming] = useState(false); @@ -144,6 +146,10 @@ export default function ImportPreview({ draftId }: { draftId: number }) { const [stale, setStale] = useState(false); const [nothingNote, setNothingNote] = useState(false); const [confirmError, setConfirmError] = useState(null); + // Generation counter for the records list: bumped on every page-0 (re)load, so a + // page-0 or show-more response that resolves after a tile/filter (or draft) switch + // is recognized as stale and dropped instead of clobbering/appending to the new list. + const recordsGen = useRef(0); async function loadDraft() { setLoadFail(null); @@ -155,32 +161,54 @@ export default function ImportPreview({ draftId }: { draftId: number }) { setDraft(resp.value); } useEffect(() => { + // A new draft means a fresh consent gate — reset everything the old one set. + setFilter(null); + setStale(false); + setConfirmError(null); + setNothingNote(false); + setRecords(null); + setRecordsError(null); + setHasMore(false); void loadDraft(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [draftId]); - useEffect(() => { - if (!draft) return; - let gone = false; + function loadRecords(kind: RecordKind | null) { + recordsGen.current += 1; + const gen = recordsGen.current; setRecords(null); + setRecordsError(null); setHasMore(false); - void getDraftRecords(draftId, filter ?? undefined, PAGE, 0).then((resp) => { - if (gone || !resp.ok) return; + void getDraftRecords(draftId, kind ?? undefined, PAGE, 0).then((resp) => { + if (gen !== recordsGen.current) return; + if (!resp.ok) { + setRecordsError("load"); + return; + } setRecords(resp.value); setHasMore(resp.value.length === PAGE); }); - return () => { - gone = true; - }; + } + useEffect(() => { + if (!draft) return; + loadRecords(filter); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [draft, draftId, filter]); async function showMore() { if (!records) return; + const gen = recordsGen.current; setMoreBusy(true); + setRecordsError(null); const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length); setMoreBusy(false); - if (!resp.ok) return; - setRecords([...records, ...resp.value]); + // Filter/draft switched while this page was in flight — drop the stale page. + if (gen !== recordsGen.current) return; + if (!resp.ok) { + setRecordsError("more"); + return; + } + setRecords((prev) => [...(prev ?? []), ...resp.value]); setHasMore(resp.value.length === PAGE); } @@ -233,7 +261,13 @@ export default function ImportPreview({ draftId }: { draftId: number }) { ); } - if (!draft) return

Loading…

; + if (!draft) { + return ( +

+ Loading… +

+ ); + } const { summary } = draft; const tiles: { kind: RecordKind; num: number; label: string }[] = [ @@ -250,10 +284,17 @@ export default function ImportPreview({ draftId }: { draftId: number }) { ← Products

Import preview — {draft.file_name}

-

{draft.dialect === "canonical" ? "Canonical format" : draft.dialect}

+

{dialectLabel(draft.dialect)}

{draft.unknown_columns.length > 0 && ( - {draft.unknown_columns.join(", ")} + {draft.unknown_columns.length > 8 ? ( +
+ {draft.unknown_columns.length} columns not imported + {draft.unknown_columns.join(", ")} +
+ ) : ( + draft.unknown_columns.join(", ") + )}
)}
@@ -271,8 +312,17 @@ export default function ImportPreview({ draftId }: { draftId: number }) { ))}
{filter === "error" && records && } - {records === null ? ( -

Loading…

+ {recordsError === "load" ? ( +

+ Couldn't load these records.{" "} + +

+ ) : records === null ? ( +

+ Loading… +

) : records.length === 0 ? (

Nothing to show here.

) : ( @@ -294,9 +344,17 @@ export default function ImportPreview({ draftId }: { draftId: number }) { + {recordsError === "more" && ( + + Couldn't load more records.{" "} + + + )}

)} -
+
{stale ? ( Upload the file again diff --git a/frontend/src/screens/products/ImportUpload.tsx b/frontend/src/screens/products/ImportUpload.tsx index a5e8421..934ccdc 100644 --- a/frontend/src/screens/products/ImportUpload.tsx +++ b/frontend/src/screens/products/ImportUpload.tsx @@ -44,7 +44,9 @@ export default function ImportUpload() { disabled={busy} onChange={(e) => void onPick(e.target.files)} /> - {busy ? "Validating…" : "Choose a CSV file"} + + {busy ? "Validating…" : "Choose a CSV file"} + CSV, up to 5,000 rows

diff --git a/frontend/src/screens/products/ProductsPage.tsx b/frontend/src/screens/products/ProductsPage.tsx index d6dca2c..b8de3f6 100644 --- a/frontend/src/screens/products/ProductsPage.tsx +++ b/frontend/src/screens/products/ProductsPage.tsx @@ -1,7 +1,13 @@ // Products page (SD-0002 §5.2) — the catalog's home: where imports start and history // lives. SLICE-5: export is disabled (SLICE-6 ships it); the browsable list is #14's. import { useEffect, useState } from "react"; -import { getProductsSummary, listRuns, type ProductsSummary, type RunSummary } from "../../productsApi"; +import { + dialectLabel, + getProductsSummary, + listRuns, + type ProductsSummary, + type RunSummary, +} from "../../productsApi"; import { Banner } from "../../ui/kit"; const STATUS_LABELS: Record = { @@ -40,7 +46,13 @@ export default function ProductsPage() { ); } - if (!summary || !runs) return

Loading…

; + if (!summary || !runs) { + return ( +

+ Loading… +

+ ); + } const empty = summary.product_count === 0; return ( @@ -51,9 +63,12 @@ export default function ProductsPage() { {!empty && · {summary.product_count.toLocaleString()}}
- +
+ + Export arrives in a coming release +
Import products @@ -95,8 +110,13 @@ export default function ProductsPage() { }} > {new Date(r.created_at).toLocaleString()} - {r.file_name} - {r.dialect} + + {/* Anchor = the keyboard/SR path (§6.6); the row onClick stays as a + mouse convenience. Both set the same hash, so the double fire on + an anchor click is idempotent. */} + {r.file_name} + + {dialectLabel(r.dialect)} {r.products_added} {r.products_updated} {r.rows_errored} diff --git a/frontend/src/screens/products/RunDetail.tsx b/frontend/src/screens/products/RunDetail.tsx index cd79704..c0a8194 100644 --- a/frontend/src/screens/products/RunDetail.tsx +++ b/frontend/src/screens/products/RunDetail.tsx @@ -1,7 +1,7 @@ // Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run. // NO images section this slice (SLICE-7). PUC-4/5/8. import { useEffect, useState } from "react"; -import { getRun, type RunDetail as RunDetailType } from "../../productsApi"; +import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi"; import { Banner } from "../../ui/kit"; const STATUS_LABELS: Record = { @@ -46,9 +46,13 @@ export default function RunDetail({ runId }: { runId: number }) { ); } - if (!run) return

Loading…

; - - const dialectLabel = run.dialect === "canonical" ? "Canonical format" : run.dialect; + if (!run) { + return ( +

+ Loading… +

+ ); + } return (
@@ -57,7 +61,7 @@ export default function RunDetail({ runId }: { runId: number }) {

{run.file_name}

- Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel} + Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}

{run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in @@ -77,7 +81,7 @@ export default function RunDetail({ runId }: { runId: number }) { {run.errors.map((e, i) => ( {e.line} - {e.column ?? ""} + {e.column ?? "—"} {e.message} ))} diff --git a/frontend/src/styles/products.css b/frontend/src/styles/products.css index 27de45f..d59d556 100644 --- a/frontend/src/styles/products.css +++ b/frontend/src/styles/products.css @@ -53,6 +53,9 @@ } .products__count { color: var(--text-on-dark-mute); font-weight: var(--weight-medium); } .products__actions { display: flex; gap: 12px; align-items: center; } +/* Disabled Export + its visible "coming release" caption, stacked. */ +.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; } +.products__export .note { font-size: 11.5px; } .products .btn-primary { width: auto; text-decoration: none; } .products .empty { margin: 24px auto 0; } @@ -160,7 +163,6 @@ /* ── diff list (preview records, Task 13) ───────────────────────────────────── */ .difflist { list-style: none; margin: 0; padding: 0; } -.difflist > li { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); } .difflist__item { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); } .difflist__item > summary { cursor: pointer;