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/api/orders/import/route.ts b/app/api/orders/import/route.ts new file mode 100644 index 0000000..ae14dfc --- /dev/null +++ b/app/api/orders/import/route.ts @@ -0,0 +1,249 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createOrder, type OrderConfig, type OrderOwner } from "@/lib/orders-store"; +import { requireOrderOwner } from "@/src/auth/require-order-owner"; +import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping"; +import { createAud0Client } from "@/src/clients/aud0-client"; +import { validateRows, defineMapping, type RowError } from "@siax/contracts/import-export"; +import { serviceById, type Flow } from "@/lib/services"; +import { ENTITY_TYPES, FREQUENCIES, type EntityType, type Frequency } from "@/lib/data"; + +// C8: CSV-import of orders — the symmetric counterpart of GET /api/orders/export +// (våg 3.4). The server is the ONLY authority: the raw CSV is parsed here +// (RFC 4180, no eval), re-validated here via the frozen @siax/contracts kernel +// (validateRows) plus host field rules, and executed through the EXISTING order +// creation logic (createOrder) so imported rows are real tenant-owned orders. +// Client-side wizard parsing is presentation entropy, never trusted. +// +// Fail-closed properties: +// - No principal (requireOrderOwner) → 401, no parse, no writes. +// - Unknown header column → 400 (never silently dropped). +// - More than MAX_IMPORT_ROWS data rows → 413 (cap, never truncate silently). +// - execute() re-validates and imports only rows that pass; failing rows are +// reported per row (row/field/code) and never created. Each order insert is +// individually atomic via createOrder (per-row transactional semantics); +// a failing row can never corrupt a passing one. +// - Audit: every successful execute emits an AUD0 event via 0pt's sole producer +// boundary (durable outbox when DATABASE_URL is configured). Emission failure +// is logged, never blocks the import (the outbox pattern owns delivery). + +export const MAX_IMPORT_ROWS = 500; +const MAX_CSV_CHARS = 1_000_000; +const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const; +const FLOWS: readonly Flow[] = ["catalog", "custom", "subscription"]; +const MAX_VOLUME = 1_000_000; + +interface ImportRowError extends RowError { + message: string; +} + +interface ParsedCsv { + rows: Record[]; + headerError?: string; +} + +/** Minimal RFC 4180 parser: quoted fields, doubled quotes, CRLF or LF. No eval. */ +export function parseCsv(text: string): ParsedCsv { + 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") { + // consumed by the following \n (or ignored for bare CR) + } else { + 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 { rows: [] }; + const header = nonEmpty[0].map((h) => h.trim().toLowerCase()); + const unknown = header.filter((h) => h !== "" && !(IMPORT_COLUMNS as readonly string[]).includes(h)); + if (unknown.length > 0) { + return { rows: [], headerError: `Okänd kolumn: ${unknown.join(", ")} (tillåtna: ${IMPORT_COLUMNS.join(", ")})` }; + } + return { + rows: nonEmpty.slice(1).map((cells) => { + const obj: Record = {}; + header.forEach((h, idx) => { + if (h) obj[h] = (cells[idx] ?? "").trim(); + }); + return obj; + }), + }; +} + +/** Host field rules on top of the kernel's required/type checks. */ +function hostValidate(rows: Record[]): ImportRowError[] { + const errors: ImportRowError[] = []; + rows.forEach((r, idx) => { + const rowNo = idx + 1; + const flow = (r.flow ?? "").trim() as Flow; + if (flow && !FLOWS.includes(flow)) { + errors.push({ row: rowNo, field: "flow", code: "bad_type", message: `Ogiltigt flow "${r.flow}" (tillåtna: ${FLOWS.join(", ")})` }); + } + const st = (r.service_type ?? "").trim(); + if (st && !serviceById(st)) { + errors.push({ row: rowNo, field: "service_type", code: "bad_type", message: `Okänd service_type "${st}"` }); + } + const type = (r.type ?? "").trim(); + if (type && !ENTITY_TYPES.some((t) => t.id === type)) { + errors.push({ row: rowNo, field: "type", code: "bad_type", message: `Ogiltig typ "${type}" (tillåtna: ${ENTITY_TYPES.map((t) => t.id).join(", ")})` }); + } + const volume = (r.volume ?? "").trim(); + if (volume === "") { + // optional — order logic applies flow defaults + } else if (!/^\d+$/.test(volume) || Number(volume) < 1 || Number(volume) > MAX_VOLUME) { + errors.push({ row: rowNo, field: "volume", code: "bad_type", message: `volume måste vara ett positivt heltal ≤ ${MAX_VOLUME}` }); + } + const freq = (r.frequency ?? "").trim(); + if (freq && !FREQUENCIES.includes(freq as Frequency)) { + errors.push({ row: rowNo, field: "frequency", code: "bad_type", message: `Ogiltig frequency "${freq}" (tillåtna: ${FREQUENCIES.join(", ")})` }); + } + }); + return errors; +} + +function buildConfig(r: Record): OrderConfig { + const config: OrderConfig = {}; + if ((r.service_type ?? "").trim()) config.service_type = r.service_type.trim(); + if ((r.type ?? "").trim()) config.type = r.type.trim() as EntityType; + if ((r.volume ?? "").trim()) config.volume = Number(r.volume.trim()); + if ((r.frequency ?? "").trim()) config.frequency = r.frequency.trim() as Frequency; + if ((r.markets ?? "").trim()) { + config.markets = r.markets.split(";").map((m) => m.trim()).filter(Boolean); + } + return config; +} + +export interface ImportReport { + totalRows: number; + validRows: number; + errorCount: number; + errors: ImportRowError[]; +} + +/** Server-authoritative validation: kernel required/type checks + host rules. */ +export function validateImport(csv: string): { report: ImportReport; rows: Record[]; headerError?: string } { + const { rows, headerError } = parseCsv(csv); + if (headerError) return { report: { totalRows: 0, validRows: 0, errorCount: 1, errors: [{ row: 0, field: "header", code: "format", message: headerError }] }, rows: [], headerError }; + const mapping = defineMapping({ columns: IMPORT_COLUMNS.map((c) => ({ source: c, target: c, required: c === "flow" })) }); + const kernel = validateRows(rows, mapping); + const host = hostValidate(rows); + const kernelErrors: ImportRowError[] = kernel.errors.map((e) => ({ ...e, message: e.message ?? `Valideringsfel (${e.code})` })); + const errors = [...kernelErrors, ...host]; + return { + report: { + totalRows: rows.length, + validRows: rows.length - new Set(errors.map((e) => e.row)).size, + errorCount: errors.length, + errors, + }, + rows, + }; +} + +export async function POST(req: NextRequest) { + const auth = await requireOrderOwner(req); + if (auth.response) return auth.response; + const owner: OrderOwner = auth.owner; + + let body: { mode?: string; csv?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON" } }, { status: 400 }); + } + const mode = body.mode; + const csv = body.csv; + if (!mode || !["validate", "dry_run", "execute"].includes(mode)) { + return NextResponse.json({ error: { code: "bad_mode", message: "mode måste vara validate | dry_run | execute" } }, { status: 400 }); + } + if (typeof csv !== "string" || csv.trim() === "") { + return NextResponse.json({ error: { code: "bad_csv", message: "csv saknas eller är tom" } }, { status: 400 }); + } + if (csv.length > MAX_CSV_CHARS) { + return NextResponse.json({ error: { code: "csv_too_large", message: `CSV större än ${MAX_CSV_CHARS} tecken` } }, { status: 413 }); + } + try { + const { report, rows, headerError } = validateImport(csv); + if (headerError) { + return NextResponse.json({ error: { code: "bad_header", message: headerError }, report }, { status: 400 }); + } + if (rows.length > MAX_IMPORT_ROWS) { + return NextResponse.json({ error: { code: "row_cap_exceeded", message: `Max ${MAX_IMPORT_ROWS} rader per import` } }, { status: 413 }); + } + + if (mode === "validate" || mode === "dry_run") { + // No writes — dry_run is exactly validate plus an explicit rehearsal count. + return NextResponse.json({ mode, ...report, would_import: report.validRows }); + } + + // mode === "execute": server re-validates and imports ONLY passing rows. + const failedRows = new Set(report.errors.map((e) => e.row)); + const orderIds: string[] = []; + let imported = 0; + for (let idx = 0; idx < rows.length; idx++) { + if (failedRows.has(idx + 1)) continue; + const r = rows[idx]; + try { + const { order } = await createOrder((r.flow ?? "").trim(), buildConfig(r), undefined, owner); + orderIds.push(order.order_id); + imported++; + } catch (err: unknown) { + report.errors.push({ row: idx + 1, field: "row", code: "format", message: `Skapning misslyckades: ${err instanceof Error ? err.message : "okänt fel"}` }); + } + } + + // AUD0 audit — 0pt's sole producer boundary; durable outbox under DATABASE_URL. + try { + const aud0 = createAud0Client(); + await aud0.ingestEvent({ + event_id: (globalThis.crypto?.randomUUID?.() ?? `evt-${Date.now()}`), + event_type: "0pt.orders.import", + tenant_id: owner.tenantId, + project_id: "0pt-orders", + actor_id: owner.subject, + app_id: "0pt", + capability_id: "orders.import", + resource_type: "order", + resource_id: orderIds[0] ?? "none", + risk_level: "L2", + payload: { imported, total_rows: report.totalRows, error_count: report.errors.length, order_ids: orderIds }, + created_at: new Date().toISOString(), + }); + } catch (auditErr: unknown) { + console.error("[0pt-orders-import] AUD0 emission failed (import not rolled back):", auditErr); + } + + return NextResponse.json({ mode, imported, totalRows: report.totalRows, validRows: report.validRows, errorCount: report.errors.length, errors: report.errors.slice(0, 100), order_ids: orderIds }); + } catch (err: unknown) { + if (isSiaxError(err)) return siaxErrorResponseForLegacy(err); + throw err; + } +} diff --git a/package.json b/package.json index b6dea96..bdd2faa 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,11 @@ "@radix-ui/react-toggle-group": "1.1.19", "@radix-ui/react-tooltip": "1.2.16", "@siax/0pt-decision": "workspace:*", - "@siax/charts": "^0.4.0", + "@siax/charts": "^0.5.0", "@siax/config": "^0.2.1", "@siax/contracts": "^0.10.0", "@siax/tokens": "^0.1.2", - "@siax/ui": "^0.12.0", + "@siax/ui": "^0.14.0", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -79,7 +79,8 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "3.25.76" + "zod": "3.25.76", + "@siax/import-export": "^0.2.0" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -99,9 +100,9 @@ "vitest": "^3.2.7" }, "pnpm": { - "onlyBuiltDependencies": [ - "sharp", - "esbuild" - ] + "overrides": { + "@siax/ui>@siax/contracts": "0.10.0", + "@siax/ui>@siax/import-export": "0.2.0" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56171a8..a0bd519 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@siax/ui>@siax/contracts': 0.10.0 + '@siax/ui>@siax/import-export': 0.2.0 + importers: .: @@ -102,20 +106,23 @@ importers: specifier: workspace:* version: link:packages/decision '@siax/charts': - specifier: ^0.4.0 - version: 0.4.0(@siax/ui@0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^0.5.0 + version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@siax/config': specifier: ^0.2.1 version: 0.2.1(tailwindcss@4.3.3) '@siax/contracts': specifier: ^0.10.0 version: 0.10.0 + '@siax/import-export': + specifier: ^0.2.0 + version: 0.2.0 '@siax/tokens': specifier: ^0.1.2 version: 0.1.2 '@siax/ui': - specifier: ^0.12.0 - version: 0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + specifier: ^0.14.0 + version: 0.14.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) autoprefixer: specifier: ^10.4.20 version: 10.5.5(postcss@8.5.28) @@ -1531,10 +1538,9 @@ packages: cpu: [x64] os: [win32] - '@siax/charts@0.4.0': - resolution: {integrity: sha512-Lbq/iDJLL3zL3qBKDaNevIUujeaJED8qLwuRt+qka771Y4QOSiwlbmTYR9Tz1F0kKNjxVvLQq178EDDmGYg4ew==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.4.0/charts-0.4.0.tgz} + '@siax/charts@0.5.0': + resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz} peerDependencies: - '@siax/ui': 0.12.0 react: ^19.2.0 react-dom: ^19.2.0 @@ -1546,11 +1552,14 @@ packages: '@siax/contracts@0.10.0': resolution: {integrity: sha512-2WVHNsR80hkwWEfVxKvi9/lhW0CDpNGLvVvyFu39bakN4+IXfo5y16gCpqs3v9NC0/0+YGt7kAW1qhsvlm/VKw==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.10.0/contracts-0.10.0.tgz} + '@siax/import-export@0.2.0': + resolution: {integrity: sha512-QCY6gXNeazsV8hBY2cGClFRMVkNKUPbpch93ZQOQVtClxJ6+JfXdQObRu7o40bt3L4/OBlMdzVW+pqPB8AKPiA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.0/import-export-0.2.0.tgz} + '@siax/tokens@0.1.2': resolution: {integrity: sha512-pz4tP8cyzhWh5gGw9GQLHjl4E/w/WemfYR5x5rdejneg+OSJYEmf6pvGCirj7247DhPqX0BH19uJK8ZN8S/Hcg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Ftokens/-/0.1.2/tokens-0.1.2.tgz} - '@siax/ui@0.12.0': - resolution: {integrity: sha512-WUsZJrvbOg46su0E+gjqyXds+4s4r5Mzuye8oXEsAHNFJPzU+90B6QjfpjAqOWCBCE6mPdXOL3te6QqYrSl5pA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.12.0/ui-0.12.0.tgz} + '@siax/ui@0.14.0': + resolution: {integrity: sha512-cpQ+USiwShCY1oiA4oCp405OdOzYZkUUQenhZY2fPneLxpmm57I11KgF5TjgT7e7VZAIg2LZcmH/7GNo1TV4XA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.14.0/ui-0.14.0.tgz} peerDependencies: react: ^19.2.0 react-dom: ^19.2.0 @@ -1669,33 +1678,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-shape@3.1.8': - resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1988,50 +1970,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-format@3.1.2: - resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} @@ -2047,9 +1985,6 @@ packages: supports-color: optional: true - decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2076,9 +2011,6 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - echarts@6.1.0: resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} @@ -2188,9 +2120,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -2198,10 +2127,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@5.4.0: - resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} - engines: {node: '>=6.0.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2314,10 +2239,6 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - interpret@3.1.1: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} @@ -2490,13 +2411,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -2568,10 +2482,6 @@ packages: resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} engines: {node: '>=18'} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2652,9 +2562,6 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -2689,9 +2596,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2724,12 +2628,6 @@ packages: react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -2740,12 +2638,6 @@ packages: '@types/react': optional: true - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - react18-json-view@0.2.10: resolution: {integrity: sha512-rYEbaCG/U4THY1qp1xY14/Kbnp9yY3W6Qm3Rmu+jlCIdxzMS5EcD+wI97kCKRoN3CuJyJU8hqkax5xWfl8A4EA==} peerDependencies: @@ -2755,17 +2647,6 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - - recharts@2.15.4: - resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} - engines: {node: '>=14'} - deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -2920,9 +2801,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3030,9 +2908,6 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4338,12 +4213,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@siax/charts@0.4.0(@siax/ui@0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@siax/ui': 0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + clsx: 2.1.1 echarts: 6.1.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + tailwind-merge: 2.6.1 '@siax/config@0.2.1(tailwindcss@4.3.3)': dependencies: @@ -4351,9 +4227,13 @@ snapshots: '@siax/contracts@0.10.0': {} + '@siax/import-export@0.2.0': + dependencies: + '@siax/contracts': 0.10.0 + '@siax/tokens@0.1.2': {} - '@siax/ui@0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)': + '@siax/ui@0.14.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)': dependencies: '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4376,8 +4256,10 @@ snapshots: '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@siax/charts': 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@siax/config': 0.2.1(tailwindcss@4.3.3) '@siax/contracts': 0.10.0 + '@siax/import-export': 0.2.0 '@siax/tokens': 0.1.2 '@tanstack/react-table': 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-virtual': 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4392,7 +4274,6 @@ snapshots: react-dom: 19.2.8(react@19.2.8) react-resizable-panels: 3.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react18-json-view: 0.2.10(react@19.2.8) - recharts: 2.15.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) sonner: 1.7.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tailwind-merge: 2.6.1 vaul: 1.1.2(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4496,30 +4377,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/d3-array@3.2.2': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-shape@3.1.8': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -4847,44 +4704,6 @@ snapshots: csstype@3.2.3: {} - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-color@3.1.0: {} - - d3-ease@3.0.1: {} - - d3-format@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.2 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - date-fns-jalali@4.1.0-0: {} date-fns@4.1.0: {} @@ -4893,8 +4712,6 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js-light@2.5.1: {} - decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -4930,11 +4747,6 @@ snapshots: diff@9.0.0: {} - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.29.7 - csstype: 3.2.3 - echarts@6.1.0: dependencies: tslib: 2.3.0 @@ -5086,14 +4898,10 @@ snapshots: esutils@2.0.3: {} - eventemitter3@4.0.7: {} - expect-type@1.4.0: {} fast-deep-equal@3.1.3: {} - fast-equals@5.4.0: {} - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -5186,8 +4994,6 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - internmap@2.0.3: {} - interpret@3.1.1: {} is-alphabetical@2.0.1: {} @@ -5314,12 +5120,6 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.18.1: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - loupe@3.2.1: {} lucide-react@0.400.0(react@19.2.8): @@ -5384,8 +5184,6 @@ snapshots: node-releases@2.0.55: {} - object-assign@4.1.1: {} - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5465,12 +5263,6 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - property-information@7.2.0: {} punycode@2.3.1: {} @@ -5508,8 +5300,6 @@ snapshots: react-is@16.13.1: {} - react-is@18.3.1: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -5539,14 +5329,6 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-smooth@4.0.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - fast-equals: 5.4.0 - prop-types: 15.8.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-transition-group: 4.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): dependencies: get-nonce: 1.0.1 @@ -5555,15 +5337,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-transition-group@4.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - '@babel/runtime': 7.29.7 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react18-json-view@0.2.10(react@19.2.8): dependencies: copy-to-clipboard: 3.3.3 @@ -5571,23 +5344,6 @@ snapshots: react@19.2.8: {} - recharts-scale@0.4.5: - dependencies: - decimal.js-light: 2.5.1 - - recharts@2.15.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - clsx: 2.1.1 - eventemitter3: 4.0.7 - lodash: 4.18.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - recharts-scale: 0.4.5 - tiny-invariant: 1.3.3 - victory-vendor: 36.9.2 - rechoir@0.8.0: dependencies: resolve: 1.22.12 @@ -5752,8 +5508,6 @@ snapshots: tapable@2.3.3: {} - tiny-invariant@1.3.3: {} - tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -5853,23 +5607,6 @@ snapshots: - '@types/react' - '@types/react-dom' - victory-vendor@36.9.2: - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-ease': 3.0.2 - '@types/d3-interpolate': 3.0.4 - '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.8 - '@types/d3-time': 3.0.4 - '@types/d3-timer': 3.0.2 - d3-array: 3.2.4 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-timer: 3.0.1 - vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15): dependencies: cac: 6.7.14 diff --git a/tests/api/orders-import.test.ts b/tests/api/orders-import.test.ts new file mode 100644 index 0000000..8cf6bd8 --- /dev/null +++ b/tests/api/orders-import.test.ts @@ -0,0 +1,156 @@ +/** + * C8: tests for POST /api/orders/import — the symmetric counterpart of the + * våg-3.4 CSV export and the first real product host of the @siax/ui + * ImportWizard kernel. Follows the suite pattern of calling the route handler + * directly with a hand-built NextRequest and mocked auth/store/audit. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const mockOwner = { owner: { tenantId: "tenant-test", subject: "buyer-test" } }; +const requireOrderOwnerMock = vi.fn(); +vi.mock("@/src/auth/require-order-owner", () => ({ + requireOrderOwner: (...args: unknown[]) => requireOrderOwnerMock(...args), +})); + +const createOrderMock = vi.fn(); +vi.mock("@/lib/orders-store", () => ({ + createOrder: (...args: unknown[]) => createOrderMock(...args), +})); + +const ingestEventMock = vi.fn().mockResolvedValue({}); +vi.mock("@/src/clients/aud0-client", () => ({ + createAud0Client: () => ({ ingestEvent: ingestEventMock }), +})); + +import { POST, parseCsv, validateImport, MAX_IMPORT_ROWS } from "@/app/api/orders/import/route"; + +function postReq(body: unknown, withAuth = true): NextRequest { + requireOrderOwnerMock.mockResolvedValue(withAuth ? mockOwner : { response: NextResponse.json({ error: "OPT_UNAUTHENTICATED" }, { status: 401 }) }); + return new NextRequest(new URL("/api/orders/import", "http://localhost"), { + method: "POST", + body: JSON.stringify(body), + headers: withAuth ? { authorization: "Bearer x" } : {}, + }); +} + +const HEADER = "flow,service_type,type,volume,frequency,markets"; + +function csv(lines: string[]): string { + return [HEADER, ...lines].join("\r\n") + "\r\n"; +} + +beforeEach(() => { + createOrderMock.mockReset(); + ingestEventMock.mockClear(); + createOrderMock.mockImplementation(async (flow: string, config: unknown, _key: unknown, owner: { tenantId: string }) => ({ + order: { order_id: `ord_${createOrderMock.mock.calls.length}`, flow, status: "draft", config, tenant_id: owner.tenantId }, + replayed: false, + })); +}); + +describe("parseCsv (server-parsed, RFC 4180, no eval)", () => { + it("parses quoted fields with doubled quotes", () => { + const { rows } = parseCsv('flow,markets\r\ncustom,"A;""B,C"""\r\n'); + expect(rows[0].flow).toBe("custom"); + expect(rows[0].markets).toBe('A;"B,C"'); + }); + + it("rejects unknown header columns (fail-closed, never silently dropped)", () => { + const { headerError } = parseCsv("flow,evil\r\ncustom,x\r\n"); + expect(headerError).toMatch(/Okänd kolumn: evil/); + }); +}); + +describe("validateImport — kernel + host rules", () => { + it("reports missing required flow as missing_required", () => { + const { report } = validateImport(csv([",icp,Bolag,10,Engångs,SE"])); + expect(report.errorCount).toBeGreaterThan(0); + expect(report.errors.some((e) => e.code === "missing_required")).toBe(true); + }); + + it("reports bad host fields with row/field/code", () => { + const { report } = validateImport(csv(["nope,icp,Bolag,-5,Engångs,SE"])); + const fields = report.errors.map((e) => e.field).sort(); + expect(fields).toContain("flow"); + expect(fields).toContain("volume"); + }); +}); + +describe("POST /api/orders/import", () => { + it("happy path: execute imports all valid rows through createOrder with the verified owner + emits AUD0", async () => { + const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "catalog,icp,Fordon,2,Engångs,", "subscription,,Beslutsfattare,50,Veckovis,SE;NO"]) })); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.imported).toBe(3); + expect(body.errors).toHaveLength(0); + expect(body.order_ids).toHaveLength(3); + expect(createOrderMock).toHaveBeenCalledTimes(3); + expect(createOrderMock.mock.calls[0][3]).toEqual({ tenantId: "tenant-test", subject: "buyer-test" }); + expect(createOrderMock.mock.calls[0][1]).toMatchObject({ service_type: "icp", type: "Bolag", volume: 10, frequency: "Engångs", markets: ["SE"] }); + expect(createOrderMock.mock.calls[2][1]).toMatchObject({ markets: ["SE", "NO"] }); + expect(ingestEventMock).toHaveBeenCalledTimes(1); + expect(ingestEventMock.mock.calls[0][0]).toMatchObject({ event_type: "0pt.orders.import", tenant_id: "tenant-test", risk_level: "L2" }); + }); + + it("partial success: failing rows are reported per row and never created; passing rows import", async () => { + const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "bogus_flow,icp,Bolag,10,Engångs,SE", "catalog,icp,Bolag,notanumber,Engångs,SE"]) })); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.imported).toBe(1); + expect(body.errorCount).toBe(2); + const badFlow = body.errors.find((e: { field: string }) => e.field === "flow"); + const badVol = body.errors.find((e: { field: string }) => e.field === "volume"); + expect(badFlow.row).toBe(2); + expect(badVol.row).toBe(3); + expect(createOrderMock).toHaveBeenCalledTimes(1); + }); + + it("validate/dry_run never write (createOrder not called)", async () => { + const r1 = await POST(postReq({ mode: "validate", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) })); + expect(r1.status).toBe(200); + const b1 = await r1.json(); + expect(b1.validRows).toBe(1); + expect(b1.would_import).toBe(1); + const r2 = await POST(postReq({ mode: "dry_run", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) })); + expect(r2.status).toBe(200); + expect(createOrderMock).not.toHaveBeenCalled(); + }); + + it("fail-closed auth: no principal → 401 and createOrder is never reached", async () => { + const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) }, false)); + expect(r.status).toBe(401); + expect(createOrderMock).not.toHaveBeenCalled(); + }); + + it("row cap: more than MAX_IMPORT_ROWS data rows → 413, no writes", async () => { + const lines = Array.from({ length: MAX_IMPORT_ROWS + 1 }, () => "custom,icp,Bolag,10,Engångs,SE"); + const r = await POST(postReq({ mode: "execute", csv: csv(lines) })); + expect(r.status).toBe(413); + expect(createOrderMock).not.toHaveBeenCalled(); + }); + + it("unknown header → 400 fail-closed", async () => { + const r = await POST(postReq({ mode: "execute", csv: "flow,evil\r\ncustom,x\r\n" })); + expect(r.status).toBe(400); + const body = await r.json(); + expect(body.error.code).toBe("bad_header"); + }); + + it("empty/missing csv → 400", async () => { + const r = await POST(postReq({ mode: "execute", csv: " " })); + expect(r.status).toBe(400); + }); + + it("execute-time creation failure is reported per row without aborting the batch", async () => { + createOrderMock.mockImplementationOnce(async () => { + throw new Error("db down"); + }); + const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "catalog,icp,Bolag,1,Engångs,SE"]) })); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.imported).toBe(1); + expect(body.errors[0].row).toBe(1); + expect(body.errors[0].message).toContain("db down"); + }); +}); diff --git a/tests/components/import-wizard-ssr.test.ts b/tests/components/import-wizard-ssr.test.ts new file mode 100644 index 0000000..4923502 --- /dev/null +++ b/tests/components/import-wizard-ssr.test.ts @@ -0,0 +1,63 @@ +/** + * C8: SSR-render proof that the host can render the @siax/ui ImportWizard over + * the @siax/import-export runtime store (the exact packages the admin import + * page uses). Node environment — react-dom/server, no DOM needed. + */ +import { describe, it, expect, vi } from "vitest"; + +// ImportWizard subscribes to the runtime store via useSyncExternalStore, which +// demands getServerSnapshot under react-dom/server. For a static render proof +// the snapshot read is enough — reactivity is the browser's concern. +vi.mock("react", async (importOriginal) => { + const React = await importOriginal(); + return { ...React, useSyncExternalStore: ((_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot()) as typeof React.useSyncExternalStore }; +}); +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { ImportWizard } from "@siax/ui"; +import { createImportRunStore } from "@siax/import-export"; +import { createImportJob } from "@siax/contracts/import-export"; + +function makeStore(rows: Record[]) { + return createImportRunStore({ + job: createImportJob({ + id: "test-import", + tenantId: "tenant-test", + entity: "orders", + format: "csv", + source: { system: "0pt-admin", channel: "web" }, + createdAt: "2026-09-30T00:00:00Z", + }), + rows, + }); +} + +describe("ImportWizard renders over the runtime store (host integration)", () => { + it("empty store → explicit empty state with the pipeline
    ", () => { + const html = renderToString( + createElement(ImportWizard, { store: makeStore([]), onExecute: async () => {} }), + ); + expect(html).toContain(" { + const store = makeStore([{ flow: "custom", service_type: "icp", type: "Bolag", volume: "10", frequency: "Engångs", markets: "SE" }]); + store.detect(); + let html = renderToString(createElement(ImportWizard, { store, columns: [{ source: "flow", target: "flow" }], onExecute: async () => {} })); + expect(html).toContain(" {} })); + expect(html).toContain("flow"); + }); + + it("failed store renders the failure report with role=alert", () => { + const store = makeStore([{ flow: "custom" }]); + store.detect(); + store.fail("Servern avvisade exekveringen"); + const html = renderToString(createElement(ImportWizard, { store, onExecute: async () => {} })); + expect(html).toContain("alert"); + expect(html).toContain("Servern avvisade exekveringen"); + }); +});