diff --git a/app/admin/import/page.tsx b/app/admin/import/page.tsx new file mode 100644 index 0000000..f89363c --- /dev/null +++ b/app/admin/import/page.tsx @@ -0,0 +1,223 @@ +"use client"; + +// C8: "Importera ordrar" — the first real product host of the @siax/ui +// ImportWizard + @siax/import-export runtime. The wizard renders the frozen +// store snapshot; THIS host owns every pipeline step's execution and all +// server I/O. The server (POST /api/orders/import) is the only authority: +// it re-parses and re-validates the raw CSV, so the client-side parse below +// exists purely to give the wizard rows to render. + +import { ImportWizard } from "@siax/ui"; +import { createImportRunStore, type ImportRunStore, type ImportRunSnapshot } from "@siax/import-export"; +import { createImportJob } from "@siax/contracts/import-export"; +import { useCallback, useState } from "react"; + +const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const; + +interface ServerReport { + imported?: number; + totalRows?: number; + validRows?: number; + errorCount?: number; + errors?: { row: number; field: string; code: string; message?: string }[]; + order_ids?: string[]; +} + +/** Client-side RFC 4180 parse — presentation only; the server re-parses the raw CSV. */ +function clientParse(text: string): Record[] { + const rows: string[][] = []; + let field = ""; + let row: string[] = []; + let inQuotes = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inQuotes) { + if (ch === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else inQuotes = false; + } else field += ch; + } else if (ch === '"') inQuotes = true; + else if (ch === ",") { + row.push(field); + field = ""; + } else if (ch === "\n") { + row.push(field); + field = ""; + rows.push(row); + row = []; + } else if (ch !== "\r") field += ch; + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + const nonEmpty = rows.filter((r) => r.length > 1 || (r[0] ?? "").trim() !== ""); + if (nonEmpty.length === 0) return []; + const header = nonEmpty[0].map((h) => h.trim().toLowerCase()); + return nonEmpty.slice(1).map((cells) => { + const obj: Record = {}; + header.forEach((h, idx) => { + if (h) obj[h] = (cells[idx] ?? "").trim(); + }); + return obj; + }); +} + +async function postImport(mode: "validate" | "dry_run" | "execute", csv: string): Promise<{ ok: boolean; status: number; data: ServerReport & { error?: { message?: string } } }> { + const res = await fetch("/api/orders/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, csv }), + }); + let data: ServerReport & { error?: { message?: string } } = {}; + try { + data = await res.json(); + } catch { + data = { error: { message: `HTTP ${res.status}` } }; + } + return { ok: res.ok, status: res.status, data }; +} + +export default function AdminImportPage() { + const [rawCsv, setRawCsv] = useState(null); + const [store, setStore] = useState(null); + const [serverReport, setServerReport] = useState(null); + const [serverError, setServerError] = useState(null); + const [recentOrders, setRecentOrders] = useState<{ order_id: string; flow: string; status: string }[] | null>(null); + + const onFile = useCallback(async (file: File) => { + const text = await file.text(); + setRawCsv(text); + setServerReport(null); + setServerError(null); + setRecentOrders(null); + const rows = clientParse(text); + const s = createImportRunStore({ + job: createImportJob({ + id: globalThis.crypto?.randomUUID?.() ?? `imp-${Date.now()}`, + tenantId: "current", + entity: "orders", + format: "csv", + source: { system: "0pt-admin", channel: "web" }, + createdAt: new Date().toISOString(), + }), + rows, + }); + s.detect(); + setStore(s); + }, []); + + const onValidate = useCallback(async () => { + if (!store || !rawCsv) return; + setServerError(null); + const { ok, status, data } = await postImport("validate", rawCsv); + setServerReport(data); + store.validate(); // local snapshot for the wizard report (same frozen kernel) + if (!ok) setServerError(`Servervalidering avvisade filen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`); + }, [store, rawCsv]); + + const onDryRun = useCallback(async () => { + if (!store || !rawCsv) return; + setServerError(null); + const { ok, status, data } = await postImport("dry_run", rawCsv); + setServerReport(data); + store.dryRun(); + if (!ok) setServerError(`Servern nekade generalrepet (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`); + }, [store, rawCsv]); + + const onExecute = useCallback(async () => { + if (!store || !rawCsv) return; + setServerError(null); + const { ok, status, data } = await postImport("execute", rawCsv); + setServerReport(data); + if (!ok) { + // Server is the authority — a failed execute is terminal for this run. + store.fail(`Servern avvisade exekveringen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`); + return; + } + // Host executor proxies the server result into the store's hard gates. + const snapshot: ImportRunSnapshot = await store.execute(async () => ({ imported: data.imported ?? 0 })); + // Reload-persistence proof: re-read the REAL order list after import. + try { + const res = await fetch("/api/orders", { cache: "no-store" }); + if (res.ok) { + const orders = (await res.json()) as { order_id: string; flow: string; status: string }[]; + const importedIds = new Set(data.order_ids ?? []); + setRecentOrders(orders.filter((o) => importedIds.has(o.order_id))); + } + } catch { + /* panel stays null — server report above still shows the truth */ + } + void snapshot; + }, [store, rawCsv]); + + return ( +
+

Importera ordrar

+

+ CSV med kolumnerna: {IMPORT_COLUMNS.join(", ")}. Endast flow är obligatorisk + (catalog | custom | subscription). Parsering och validering sker på servern — + filen laddas inte upp förrän ett steg körs. +

+ { + const f = e.target.files?.[0]; + if (f) void onFile(f); + }} + /> + {store && ( +
+ ({ source: c, target: c }))} + onMap={() => { + store.map({ columns: IMPORT_COLUMNS.map((c) => ({ source: c, target: c, required: c === "flow" })) }); + }} + onValidate={onValidate} + onDryRun={onDryRun} + onExecute={onExecute} + /> +
+ )} + {serverError && ( +

+ {serverError} +

+ )} + {serverReport && (serverReport.errorCount ?? 0) > 0 && ( +
+

Felrader (serverns rapport)

+
    + {(serverReport.errors ?? []).map((e, i) => ( +
  • + Rad {e.row} · {e.field} · {e.code} + {e.message ? ` — ${e.message}` : ""} +
  • + ))} +
+
+ )} + {recentOrders && ( +
+

Importerade ordrar (lästa från /api/orders efter import)

+ {recentOrders.length === 0 ? ( +

Inga importerade ordrar hittades vid reload.

+ ) : ( +
    + {recentOrders.map((o) => ( +
  • + {o.order_id} — {o.flow} — {o.status} +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/app/admin/notes/page.tsx b/app/admin/notes/page.tsx new file mode 100644 index 0000000..97a0de4 --- /dev/null +++ b/app/admin/notes/page.tsx @@ -0,0 +1,238 @@ +"use client"; + +// Admin-yta: anteckningar på order — @siax/collaboration-kernelns första +// produktkonsument (spår C). Allt nätverk går via lib/order-notes-client.ts +// (kernel-transport över host-API): offline-läge = medvetet no-op med varning +// (utkastet bevaras, ingen tyst förlust), stale skrivning = 409 -> banner + +// reload av serverns aktuella version. +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { PortalShell } from "@/components/portal-shell"; +import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/states"; +import { + ConflictError, + OfflineError, + OrderNotesClient, + type OrderNoteDto, +} from "@/lib/order-notes-client"; + +interface Order { + order_id: string; + flow: string; + status: string; + created_at: string; +} + +export default function OrderNotesPage() { + const [orders, setOrders] = useState(null); + const [orderId, setOrderId] = useState(""); + const [notes, setNotes] = useState(null); + const [loadError, setLoadError] = useState(false); + const [offlineMode, setOfflineMode] = useState(false); + const [draft, setDraft] = useState(""); + const [editing, setEditing] = useState<{ note: OrderNoteDto; body: string } | null>(null); + + const client = useMemo(() => new OrderNotesClient("admin-simon"), []); + + useEffect(() => { + client.transport.setOffline(offlineMode); + }, [client, offlineMode]); + + useEffect(() => { + (async () => { + try { + const res = await fetch("/api/orders", { cache: "no-store" }); + if (!res.ok) throw new Error("orders fetch failed"); + const body = await res.json(); + setOrders(Array.isArray(body) ? body : []); + } catch { + setLoadError(true); + } + })(); + }, []); + + const loadNotes = useCallback( + async (id: string) => { + setNotes(null); + setLoadError(false); + try { + const { notes: loaded } = await client.load(id); + setNotes(loaded); + } catch { + setLoadError(true); + } + }, + [client], + ); + + const addNote = useCallback(async () => { + if (!orderId || !draft.trim()) return; + if (offlineMode) { + // Offline = klient-varning + no-op. Utkastet ligger kvar i rutan — + // INGET skickas, inget försvinner tyst. + toast.warning("Offline-läge: anteckningen SKICKAS EJ. Utkastet är bevarat i rutan."); + return; + } + try { + const note = await client.add(orderId, draft); + setDraft(""); + setNotes((prev) => [...(prev ?? []), note]); + toast.success("Anteckning sparad"); + } catch (err) { + if (err instanceof OfflineError) { + toast.warning("Offline: inget skickades — utkastet är bevarat."); + return; + } + setLoadError(true); + } + }, [client, draft, offlineMode, orderId]); + + const saveEdit = useCallback(async () => { + if (!editing) return; + if (offlineMode) { + toast.warning("Offline-läge: ändringen SKICKAS EJ. Texten är bevarad i redigeringsrutan."); + return; + } + try { + const updated = await client.update(editing.note, editing.body, editing.note.version); + setNotes((prev) => (prev ?? []).map((n) => (n.note_id === updated.note_id ? updated : n))); + setEditing(null); + toast.success("Anteckning uppdaterad"); + } catch (err) { + if (err instanceof ConflictError) { + // Någon annan skrev samtidigt (stale version -> 409). Ladda om + // serverns aktuella version — förlora ALDRIG den lokala texten tyst. + toast.error(`Konflikt: anteckningen ändrades av någon annan (serverversion ${err.currentVersion}). Laddar om — din text finns kvar nedan.`); + setDraft((prev) => (prev ? prev : editing.body)); + await loadNotes(orderId); + setEditing(null); + return; + } + if (err instanceof OfflineError) { + toast.warning("Offline: inget skickades — ändringen är bevarad."); + return; + } + setLoadError(true); + } + }, [client, editing, loadNotes, offlineMode, orderId]); + + return ( + + + {offlineMode && ( +
+ Offline-läge aktivt: inga anteckningar skickas till servern. Utkast bevaras lokalt — + ingen tyst förlust. +
+ )} + + {loadError ? ( + orderId && loadNotes(orderId)} /> + ) : orders === null ? ( + + ) : orders.length === 0 ? ( + + ) : ( +
+ + + {orderId === "" ? ( + + ) : notes === null ? ( + + ) : notes.length === 0 ? ( + + ) : ( +
    + {notes.map((n) => ( +
  • +
    + + {n.author_id} · v{n.version} · klocka {n.clock} + + {new Date(n.updated_at).toLocaleString("sv-SE")} +
    + {editing?.note.note_id === n.note_id ? ( +
    +