"use client" import { Button, Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, } from "@conusai-kit/ui/primitives" // Imports a CSV file: the browser reads it, hashes it and sends it to the service in chunks of the size one // request may carry, each carrying the import's revision from the chunk before. A row the service refuses // is listed with its reason; the others stay applied. Sending the same file again applies nothing twice. import type { ProjectSlugs } from "@conusai-kit/ui" import { describeError, useWrite } from "@conusai-kit/ui/data" import { serviceNotice } from "@conusai-kit/ui/patterns" import { Alert, AlertDescription, Field, FieldDescription, FieldLabel, Input } from "@conusai-kit/ui/primitives" import { type FormEvent, type ReactElement, useState } from "react" import { type ImportOutcomeView, type ImportRow, MAX_IMPORT_BYTES } from "../contract/index.js" import { chunksOf, parseProductCsv } from "./import-rows.js" export interface ImportFormLabels { readonly heading: string readonly file: string readonly hint: string readonly submit: string readonly busy: string /** `{created} created, ...`: the component fills the counts. */ readonly done: string readonly empty: string readonly tooLarge: string /** `... lacks: {columns}` */ readonly missing: string readonly refusedCaption: string readonly row: string readonly reason: string readonly reasons: Readonly> readonly failed: string /** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */ readonly refusals: Readonly> } type Refused = { sourceRow: number; reason: string | null } type State = | { kind: "idle" } | { kind: "busy" } | { kind: "message"; text: string } | { kind: "done"; summary: string; refused: Refused[] } async function sha256(bytes: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", bytes) return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") } export function ImportForm({ slugs, labels }: { slugs: ProjectSlugs; labels: ImportFormLabels }): ReactElement { const apply = useWrite< ProjectSlugs & { fileHash: string sourceName: string format: "csv" chunk: number finalChunk: boolean revision?: string rows: ImportRow[] requestId: string }, ImportOutcomeView >("catalog.import.apply") const [state, setState] = useState({ kind: "idle" }) const submit = async (event: FormEvent): Promise => { event.preventDefault() const file = new FormData(event.currentTarget).get("file") if (!(file instanceof File) || file.size === 0) return if (file.size > MAX_IMPORT_BYTES) { setState({ kind: "message", text: labels.tooLarge }) return } setState({ kind: "busy" }) try { const parsed = parseProductCsv(await file.text()) if (parsed.kind === "unreadable") { setState({ kind: "message", text: labels.missing.replace("{columns}", parsed.missing.join(", ")) }) return } const chunks = chunksOf(parsed.rows) if (chunks.length === 0) { setState({ kind: "message", text: labels.empty }) return } const fileHash = await sha256(await file.arrayBuffer()) const refused: Refused[] = [] let last: ImportOutcomeView | undefined for (const [chunk, rows] of chunks.entries()) { last = await apply.mutate({ ...slugs, fileHash, sourceName: file.name, format: "csv", chunk, finalChunk: chunk === chunks.length - 1, ...(last === undefined ? {} : { revision: last.import.revision }), rows, requestId: crypto.randomUUID(), }) for (const row of last.rows) { if (row.outcome === "refused") refused.push({ sourceRow: row.sourceRow, reason: row.reason }) } } const totals = last?.import setState({ kind: "done", summary: labels.done .replace("{created}", String(totals?.created ?? 0)) .replace("{updated}", String(totals?.updated ?? 0)) .replace("{unchanged}", String(totals?.unchanged ?? 0)) .replace("{refused}", String(totals?.refused ?? 0)), refused, }) } catch (error) { setState({ kind: "message", text: labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)), }) } } const reasonOf = (reason: string | null): string => (reason !== null && labels.reasons[reason as keyof typeof labels.reasons]) || (reason ?? "") return (

{labels.heading}

{labels.file} {labels.hint} {state.kind === "message" ? ( {state.text} ) : null} {state.kind === "done" ? ( <>

{state.summary}

{state.refused.length > 0 ? ( {labels.refusedCaption} {labels.row} {labels.reason} {state.refused.map((row) => ( {row.sourceRow} {reasonOf(row.reason)} ))}
) : null} ) : null}
) }