diff --git a/frontend/src/__tests__/api/inflow-lab-proxy.test.ts b/frontend/src/__tests__/api/inflow-lab-proxy.test.ts index cb9a190b9..7185227f5 100644 --- a/frontend/src/__tests__/api/inflow-lab-proxy.test.ts +++ b/frontend/src/__tests__/api/inflow-lab-proxy.test.ts @@ -171,6 +171,59 @@ describe('Inflow Lab API proxy', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); + it('allows GET scenarios and rejects POST scenarios', async () => { + mockSessionThenUpstream( + new Response(JSON.stringify({ scenarios: [{ id: 'leafy_greens_supplier', label: 'Leafy greens supplier' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const allowed = await GET( + makeRequest({ url: 'http://localhost/api/inflow-lab/scenarios' }), + { params: Promise.resolve({ path: ['scenarios'] }) }, + ); + + expect(allowed.status).toBe(200); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + new URL('http://inflow.test/api/scenarios'), + expect.objectContaining({ + method: 'GET', + cache: 'no-store', + redirect: 'manual', + }), + ); + await expect(allowed.json()).resolves.toEqual({ + scenarios: [{ id: 'leafy_greens_supplier', label: 'Leafy greens supplier' }], + }); + + mockFetch.mockClear(); + // Session must validate before the allowlist rejects the method/path. + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ tenant_id: '11111111-1111-4111-8111-111111111111' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const rejected = await POST( + makeRequest({ + method: 'POST', + url: 'http://localhost/api/inflow-lab/api/scenarios', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'leafy_greens_supplier' }), + }), + { params: Promise.resolve({ path: ['api', 'scenarios'] }) }, + ); + expect(rejected.status).toBe(400); + await expect(rejected.json()).resolves.toMatchObject({ + ok: false, + code: 'INVALID_PATH', + }); + // Auth/me only — no upstream proxy call for disallowed POST scenarios. + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('returns exact 404 in production unless the server-only proxy flag is enabled', async () => { vi.stubEnv('NODE_ENV', 'production'); vi.stubEnv('REGENGINE_ENABLE_INFLOW_LAB_PROXY', ''); diff --git a/frontend/src/__tests__/lib/inflow-error.test.ts b/frontend/src/__tests__/lib/inflow-error.test.ts new file mode 100644 index 000000000..116e19844 --- /dev/null +++ b/frontend/src/__tests__/lib/inflow-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { inflowErrorMessage } from '@/lib/inflow-error'; + +describe('Inflow connection errors', () => { + it('explains an empty disabled-route 404 without claiming the backend is down', () => { + expect(inflowErrorMessage(404, {})).toContain('Core backend health does not include the simulator'); + }); + it('preserves a specific upstream error', () => { + expect(inflowErrorMessage(404, { error: 'Fixture missing' })).toBe('Fixture missing'); + }); + it('reports other status failures', () => { + expect(inflowErrorMessage(502, {})).toBe('Inflow Lab request failed: 502'); + }); + it('preserves a session recovery hint', () => { + expect( + inflowErrorMessage(401, { + error: 'Authenticated browser session required', + hint: 'Refresh the page and sign in again before using live Inflow Lab actions.', + }), + ).toContain('Refresh the page and sign in again'); + }); +}); diff --git a/frontend/src/app/api/inflow-lab/[...path]/route.ts b/frontend/src/app/api/inflow-lab/[...path]/route.ts index 21258c860..15964d1cf 100644 --- a/frontend/src/app/api/inflow-lab/[...path]/route.ts +++ b/frontend/src/app/api/inflow-lab/[...path]/route.ts @@ -15,7 +15,7 @@ const JSON_CONTENT_TYPES = ["application/json", "application/problem+json"]; type BoundaryMode = "simulator" | "live-export"; type EndpointContract = { - name: "health" | "status" | "records" | "lineage" | "run" | "reset" | "fixture" | "export"; + name: "health" | "status" | "records" | "lineage" | "run" | "reset" | "fixture" | "export" | "scenarios"; boundary: BoundaryMode; stream: boolean; }; @@ -106,6 +106,10 @@ function endpointContract(path: string, method: string): EndpointContract | null if (method === "GET" && path === "/api/events") { return { name: "records", boundary: "simulator", stream: false }; } + // Accept both /api/scenarios (service path) and /scenarios (short path). + if (method === "GET" && (path === "/api/scenarios" || path === "/scenarios")) { + return { name: "scenarios", boundary: "simulator", stream: false }; + } if (method === "GET" && /^\/api\/lineage\/[^/]+$/.test(path)) { return { name: "lineage", boundary: "simulator", stream: false }; } @@ -128,6 +132,12 @@ function endpointContract(path: string, method: string): EndpointContract | null return null; } +/** Map short proxy paths onto the Inflow Lab service routes. */ +function upstreamPathFor(relativePath: string): string { + if (relativePath === "/scenarios") return "/api/scenarios"; + return relativePath; +} + function copyAllowedSearchParams(source: URLSearchParams, target: URLSearchParams) { for (const [key, value] of source.entries()) { if (/^[a-zA-Z0-9_.-]{1,64}$/.test(key)) { @@ -193,7 +203,12 @@ function responseHeaders(contract: EndpointContract, upstream?: Response) { function isOptionalReadProbe(contract: EndpointContract, method: string) { return ( method === "GET" && - (contract.name === "health" || contract.name === "status" || contract.name === "records") + ( + contract.name === "health" + || contract.name === "status" + || contract.name === "records" + || contract.name === "scenarios" + ) ); } @@ -204,6 +219,7 @@ function optionalOfflineResponse(contract: EndpointContract, detail?: string, up available: false, offline: true, events: contract.name === "records" ? [] : undefined, + scenarios: contract.name === "scenarios" ? [] : undefined, status: contract.name === "status" ? "offline" : undefined, health: contract.name === "health" ? "offline" : undefined, detail, @@ -273,12 +289,12 @@ async function proxyInflowLab(request: NextRequest, params: Promise<{ path?: str 400, "INVALID_PATH", { - hint: "Allowed Inflow Lab routes are health, status, records, lineage, run/reset, fixture load, and export links.", + hint: "Allowed Inflow Lab routes are health, status, records, scenarios, lineage, run/reset, fixture load, and export links.", } ); } - const upstreamUrl = new URL(`${serviceBaseUrl()}${relativePath}`); + const upstreamUrl = new URL(`${serviceBaseUrl()}${upstreamPathFor(relativePath)}`); copyAllowedSearchParams(request.nextUrl.searchParams, upstreamUrl.searchParams); const headers = new Headers(); diff --git a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx new file mode 100644 index 000000000..382880ded --- /dev/null +++ b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx @@ -0,0 +1,2949 @@ +"use client"; + +import Link from "next/link"; +import { inflowErrorMessage } from "@/lib/inflow-error"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AlertTriangle, + ArrowDownToLine, + CalendarDays, + CheckCircle2, + ClipboardList, + Database, + FileJson, + FileSpreadsheet, + GitBranch, + GraduationCap, + Info, + PackageCheck, + Play, + RefreshCcw, + RotateCcw, + Search, + SlidersHorizontal, + Truck, + Upload, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { fetchWithCsrf } from "@/lib/fetch-with-csrf"; +import { trackToolRunCompleted, trackToolRunStarted } from "@/lib/conversion-events"; +import { useAuth } from "@/lib/auth-context"; +import { useTenant } from "@/lib/tenant-context"; +import { cn } from "@/lib/utils"; +import { Tutorial } from "@/components/ui/tutorial"; +import { + INFLOW_LAB_CLIENT_TUTORIAL_BANNER_KEY, + INFLOW_LAB_CLIENT_TUTORIAL_STORAGE_KEY, + InflowLabTutorialBanner, + buildInflowLabClientTutorialSteps, + dismissInflowLabTutorialBanner, +} from "./InflowLabTutorial"; + +type RunStage = "loaded" | "generating" | "delivering" | "validating" | "complete" | "exported"; +type EventStatus = "posted" | "validated" | "queued" | "generated" | "failed"; +type CteState = "complete" | "missing"; +type DeliveryState = "posted" | "generated" | "failed"; +type LotReadinessState = "ready" | "warning" | "blocked"; + +type TraceEvent = { + id: number; + cte: string; + lotCode: string; + product: string; + location: string; + timestamp: string; + mode: "simulation" | "uploaded"; + attempts: number; + deliveryStatus: DeliveryState; +}; + +type LineageNode = { + cte: string; + location: string; + time: string; + state: CteState; +}; + +type OptionalOfflinePayload = { + available?: boolean; + offline?: boolean; + detail?: string; +}; + +type InflowHealth = OptionalOfflinePayload & { + ok?: boolean; + build?: { version?: string; commit_sha_short?: string }; +}; + +type InflowStatus = OptionalOfflinePayload & { + running?: boolean; + config?: { + source?: string; + scenario?: string; + delivery?: { mode?: string; tenant_id?: string | null }; + }; + stats?: { + total_records?: number; + unique_lots?: number; + delivery?: { + posted?: number; + failed?: number; + generated?: number; + attempts?: number; + last_attempt_at?: string | null; + last_success_at?: string | null; + }; + }; +}; + +type InflowRecord = { + sequence_no: number; + destination_mode: string; + delivery_status: "generated" | "posted" | "failed"; + delivery_attempts?: number; + event: { + cte_type: string; + traceability_lot_code: string; + product_description: string; + location_name: string; + timestamp: string; + }; +}; + +type InflowLineagePayload = { + records?: InflowRecord[]; +}; + +function optionalOfflineDetail(...payloads: OptionalOfflinePayload[]) { + const offline = payloads.find((payload) => payload.offline || payload.available === false); + return offline ? offline.detail || "Inflow Lab service unavailable" : null; +} + +type SandboxEventEvaluation = { + event_index: number; + cte_type: string; + traceability_lot_code: string; + product_description: string; + kde_errors: string[]; + rules_failed: number; + rules_warned: number; + compliant: boolean; + blocking_defects?: { + rule_title: string; + severity: string; + result: string; + why_failed: string | null; + remediation: string | null; + category: string; + }[]; + all_results?: { + rule_title: string; + severity: string; + result: string; + why_failed: string | null; + remediation: string | null; + category: string; + }[]; +}; + +type SandboxEvaluationResult = { + total_events: number; + compliant_events: number; + non_compliant_events: number; + total_kde_errors: number; + total_rule_failures: number; + submission_blocked: boolean; + blocking_reasons: string[]; + duplicate_warnings?: string[]; + entity_warnings?: string[]; + normalizations?: { field: string; original: string; normalized: string; action_type: string }[]; + events: SandboxEventEvaluation[]; +}; + +type FeederDiagnosis = { + status: "clear" | "needs_work" | "blocked"; + headline: string; + impact: string; + buckets: { id: string; label: string; count: number; tone: "danger" | "warning" | "success" | "info" }[]; +}; + +type FeederRemediationStep = { + title: string; + detail: string; +}; + +type FixQueueItem = { + id: string; + title: string; + owner: string; + status: "open" | "waiting" | "corrected" | "accepted"; + severity: "blocked" | "warning" | "info"; + impact: string; + source: string; +}; + +type WorkbenchScenario = { + id: string; + tenant_id?: string; + name: string; + outcome: string; + records: string; + csv: string; + created_at?: string; + built_in?: boolean; +}; + +type ReadinessSummary = { + score: number; + label: string; + components: { id: string; label: string; score: number; detail: string }[]; +}; + +type CommitGateDecision = { + mode: "simulation" | "preflight" | "staging" | "production_evidence"; + allowed: boolean; + export_eligible: boolean; + reasons: string[]; + next_state: string; +}; + +type WorkbenchRunResponse = { + run_id: string; + tenant_id: string; + source: string; + readiness: ReadinessSummary; + fix_queue: FixQueueItem[]; + commit_gate: CommitGateDecision; + saved_at: string; +}; + +const INFLOW_API = "/api/inflow-lab"; +const WORKBENCH_API = "/api/ingestion/api/v1/inflow-workbench"; +const REQUIRED_CTES = ["harvesting", "cooling", "initial_packing", "shipping", "receiving"]; +const SANDBOX_CTE_TYPES = [ + "harvesting", + "cooling", + "initial_packing", + "first_land_based_receiving", + "shipping", + "receiving", + "transformation", +]; +const FEEDER_SAMPLE_CSV = `cte_type,traceability_lot_code,product_description,quantity,unit_of_measure,location_name,timestamp,harvest_date,cooling_date,packing_date,ship_date,receive_date,ship_from_location,ship_to_location,receiving_location,immediate_previous_source,reference_document,tlc_source_reference,harvester_business_name,harvest_location_ref +harvesting,TLC-FEED-001,Romaine Lettuce,120,cases,Example Grower Co,2026-04-26T15:20:00Z,2026-04-26,,,,,,,,,HARV-001,SRC-HARV-001,Example Grower Co,Example Grower Field 7 +cooling,TLC-FEED-001,Romaine Lettuce,120,cases,Salinas Cooling Hub,2026-04-26T18:12:00Z,,2026-04-26,,,,,,,,COOL-001,SRC-COOL-001,,Example Grower Field 7 +initial_packing,TLC-FEED-001,Romaine Lettuce,118,cases,Salinas Packhouse,2026-04-26T20:12:00Z,,,2026-04-26,,,,,,,PACK-001,SRC-PACK-001,Example Grower Co,Example Grower Field 7 +shipping,TLC-FEED-001,Romaine Lettuce,118,cases,Salinas Packout Dock,2026-04-26T22:41:00Z,,,,2026-04-26,,Salinas Packhouse,Bay Area DC,,,BOL-001,SRC-PACK-001,,Example Grower Field 7 +receiving,TLC-FEED-001,Romaine Lettuce,118,cases,Bay Area DC,2026-04-27T02:04:00Z,,,,,2026-04-27,,,Bay Area DC,Salinas Packout Dock,REC-001,SRC-PACK-001,,Example Grower Field 7`; +const MISSING_DESTINATION_CSV = `cte_type,traceability_lot_code,product_description,quantity,unit_of_measure,location_name,timestamp,harvest_date,cooling_date,packing_date,ship_date,receive_date,ship_from_location,ship_to_location,receiving_location,immediate_previous_source,reference_document,tlc_source_reference,harvester_business_name,harvest_location_ref +harvesting,TLC-FEED-002,Romaine Lettuce,104,cases,Example Grower Co,2026-04-26T15:20:00Z,2026-04-26,,,,,,,,,HARV-002,SRC-HARV-002,Example Grower Co,Example Grower Field 11 +cooling,TLC-FEED-002,Romaine Lettuce,104,cases,Salinas Cooling Hub,2026-04-26T18:12:00Z,,2026-04-26,,,,,,,,COOL-002,SRC-COOL-002,,Example Grower Field 11 +initial_packing,TLC-FEED-002,Romaine Lettuce,103,cases,Salinas Packhouse,2026-04-26T20:12:00Z,,,2026-04-26,,,,,,,PACK-002,SRC-PACK-002,Example Grower Co,Example Grower Field 11 +shipping,TLC-FEED-002,Romaine Lettuce,103,cases,Salinas Packout Dock,2026-04-26T22:41:00Z,,,,2026-04-26,,Salinas Packhouse,,,,BOL-002,SRC-PACK-002,,Example Grower Field 11 +receiving,TLC-FEED-002,Romaine Lettuce,103,cases,Bay Area DC,2026-04-27T02:04:00Z,,,,,2026-04-27,,,Bay Area DC,Salinas Packout Dock,REC-002,SRC-PACK-002,,Example Grower Field 11`; +const BROKEN_LINEAGE_CSV = `cte_type,traceability_lot_code,product_description,quantity,unit_of_measure,location_name,timestamp,harvest_date,cooling_date,packing_date,ship_date,receive_date,ship_from_location,ship_to_location,receiving_location,immediate_previous_source,reference_document,tlc_source_reference,harvester_business_name,harvest_location_ref,transformation_date,input_traceability_lot_codes +harvesting,TLC-FEED-003,Romaine Lettuce,220,cases,Desert Bloom Farm,2026-04-26T14:43:00Z,2026-04-26,,,,,,,,,HARV-003,SRC-HARV-003,Desert Bloom Farm,Desert Bloom Farm Block 3,, +cooling,TLC-FEED-003,Romaine Lettuce,220,cases,Imperial Pre-Cool Facility,2026-04-26T17:51:00Z,,2026-04-26,,,,,,,,COOL-003,SRC-COOL-003,,Desert Bloom Farm Block 3,, +transformation,TLC-FEED-003-CUT,Romaine Lettuce,218,cases,LA Fresh Cut Kitchen,2026-04-27T00:10:00Z,,,,,,,,,,TRANS-003,SRC-MISSING-PACK,,Desert Bloom Farm Block 3,2026-04-27,`; +const FEEDER_SAVED_RUN_KEY = "regengine:inflow-lab:last-feeder-run"; + +const scenarioLibrary: WorkbenchScenario[] = [ + { + id: "complete-romaine-flow", + name: "Complete romaine lettuce flow", + outcome: "Export-ready full chain", + records: "5 CTE records", + csv: FEEDER_SAMPLE_CSV, + }, + { + id: "missing-shipping-destination", + name: "Missing shipping destination", + outcome: "Blocked shipping KDE", + records: "5 CTE records", + csv: MISSING_DESTINATION_CSV, + }, + { + id: "broken-lineage", + name: "Transformation-style broken lineage", + outcome: "Incomplete lot chain", + records: "3 CTE records", + csv: BROKEN_LINEAGE_CSV, + }, +]; + +function servicePath(path: string) { + return `${INFLOW_API}${path}`; +} + +function safeHref(raw: string): string { + try { + const parsed = new URL(raw, "https://regengine.invalid"); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return raw.startsWith("/") ? `${parsed.pathname}${parsed.search}${parsed.hash}` : parsed.toString(); + } + } catch { + // fall through + } + return "#"; +} + + + +async function inflowJson(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + // Mutating /api/inflow-lab calls are CSRF-protected by proxy.ts; match workbenchJson. + const response = await fetchWithCsrf(servicePath(path), { + cache: "no-store", + ...init, + headers, + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(inflowErrorMessage(response.status, payload)); + } + return response.json(); +} + +async function workbenchJson(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const response = await fetchWithCsrf(`${WORKBENCH_API}${path}`, { + cache: "no-store", + ...init, + headers, + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.error || payload.detail || `Inflow Workbench request failed: ${response.status}`); + } + return response.json(); +} + +function formatServiceTime(value: string) { + return new Date(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function toTraceEvent(record: InflowRecord): TraceEvent { + return { + id: record.sequence_no, + cte: record.event.cte_type, + lotCode: record.event.traceability_lot_code, + product: record.event.product_description, + location: record.event.location_name, + timestamp: formatServiceTime(record.event.timestamp), + mode: "simulation", + attempts: record.delivery_attempts || 0, + deliveryStatus: record.delivery_status, + }; +} + +function sandboxEventToTraceEvent(event: SandboxEventEvaluation): TraceEvent { + return { + id: event.event_index + 1, + cte: event.cte_type, + lotCode: event.traceability_lot_code || `row-${event.event_index + 1}`, + product: event.product_description || "Uploaded traceability record", + location: `Uploaded row ${event.event_index + 1}`, + timestamp: formatServiceTime(new Date().toISOString()), + mode: "uploaded", + attempts: 1, + deliveryStatus: event.compliant ? "posted" : "generated", + }; +} + +function lineageByLotFromEvents(traceEvents: TraceEvent[]) { + return traceEvents.reduce>((current, event) => { + const lotEvents = traceEvents.filter((candidate) => candidate.lotCode === event.lotCode); + current[event.lotCode] = toLineage(lotEvents); + return current; + }, {}); +} + +function formatCteLabel(cte: string) { + if (cte === "receiving") return "DC receiving"; + return cte.replaceAll("_", " "); +} + +function toLineage(eventsForLot: TraceEvent[]): LineageNode[] { + const byCte = new Map(eventsForLot.map((event) => [event.cte, event])); + return REQUIRED_CTES.map((cte) => { + const event = byCte.get(cte); + return { + cte: formatCteLabel(cte), + location: event?.location || "Not captured", + time: event?.timestamp || "Missing CTE", + state: event ? "complete" : "missing", + }; + }); +} + +function getLotReadiness(lineage: LineageNode[], lotEvents: TraceEvent[]) { + const completeCount = lineage.filter((node) => node.state === "complete").length; + const totalCount = lineage.length; + const failedDeliveries = lotEvents.filter((event) => event.deliveryStatus === "failed").length; + const generatedOnly = lotEvents.filter((event) => event.deliveryStatus === "generated").length; + const missingCtes = lineage.filter((node) => node.state === "missing").map((node) => node.cte); + const hasCompleteLineage = totalCount > 0 && completeCount === totalCount; + + if (failedDeliveries > 0 || lotEvents.length === 0) { + return { + state: "blocked" as LotReadinessState, + label: "blocked", + completeCount, + totalCount, + missingCtes, + deliveryLabel: failedDeliveries > 0 ? "delivery failed" : "no events", + exportReady: false, + }; + } + + if (!hasCompleteLineage || generatedOnly > 0) { + return { + state: "warning" as LotReadinessState, + label: "exception", + completeCount, + totalCount, + missingCtes, + deliveryLabel: generatedOnly > 0 ? "generated only" : "posted", + exportReady: false, + }; + } + + return { + state: "ready" as LotReadinessState, + label: "test complete", + completeCount, + totalCount, + missingCtes, + deliveryLabel: "posted", + exportReady: true, + }; +} + +function summarizeFeederDiagnosis(result: SandboxEvaluationResult): FeederDiagnosis { + const issueCount = result.total_kde_errors + result.total_rule_failures; + const status: FeederDiagnosis["status"] = result.submission_blocked + ? "blocked" + : issueCount > 0 || result.non_compliant_events > 0 + ? "needs_work" + : "clear"; + + return { + status, + headline: + status === "clear" + ? "CSV check found no blocking fixes" + : status === "blocked" + ? "CSV check found blockers before mapping" + : "CSV check found corrections before mapping", + impact: + status === "clear" + ? "This test run can move into import mapping, but it still cannot create formal production records." + : "Correct the highlighted rows and fields before treating this source as a monitored production feed.", + buckets: [ + { id: "events", label: "Rows checked", count: result.total_events, tone: "info" }, + { id: "passed", label: "Rows passing", count: result.compliant_events, tone: "success" }, + { id: "kde", label: "KDE fixes", count: result.total_kde_errors, tone: result.total_kde_errors ? "warning" : "success" }, + { id: "rules", label: "Rule failures", count: result.total_rule_failures, tone: result.total_rule_failures ? "danger" : "success" }, + ], + }; +} + +function buildFeederRemediationPlan(result: SandboxEvaluationResult): FeederRemediationStep[] { + const steps: FeederRemediationStep[] = []; + if (result.total_kde_errors > 0) { + steps.push({ + title: "Correct missing KDE fields", + detail: "Fill required lot, location, quantity, timestamp, and handoff fields in the source CSV before mapping.", + }); + } + if (result.total_rule_failures > 0) { + steps.push({ + title: "Resolve failed FSMA checks", + detail: "Review the failed row-level rules, fix source data, and rerun the CSV check.", + }); + } + if (result.duplicate_warnings?.length || result.entity_warnings?.length) { + steps.push({ + title: "Review duplicate and entity warnings", + detail: "Confirm aliases, facility names, and duplicate rows before connecting a production feed.", + }); + } + steps.push({ + title: "Map only after diagnosis", + detail: "Use the test run to configure import mapping; formal production records still require signed-in imports.", + }); + return steps; +} + +function buildFixQueue(result: SandboxEvaluationResult | null, lotSummaries: { + lotCode: string; + product: string; + missingCtes: string[]; + readiness: ReturnType; +}[]): FixQueueItem[] { + const items: FixQueueItem[] = []; + + lotSummaries + .filter((lot) => !lot.readiness.exportReady) + .forEach((lot) => { + items.push({ + id: `lot-${lot.lotCode}`, + title: lot.missingCtes.length + ? `${lot.product} missing KDE evidence for ${lot.missingCtes.join(", ")}` + : `${lot.product} needs delivery review before evidence handoff`, + owner: lot.readiness.state === "blocked" ? "Integration owner" : "Operations analyst", + status: lot.readiness.state === "blocked" ? "open" : "waiting", + severity: lot.readiness.state === "blocked" ? "blocked" : "warning", + impact: "Excluded from export-ready counts until lineage and delivery are complete.", + source: lot.lotCode, + }); + }); + + result?.events + .filter((event) => !event.compliant || event.kde_errors.length || event.rules_failed > 0) + .slice(0, 5) + .forEach((event) => { + const firstDefect = event.kde_errors[0] || event.blocking_defects?.[0]?.rule_title || "failed rule evaluation"; + items.push({ + id: `row-${event.event_index}`, + title: `Row ${event.event_index + 1} ${event.cte_type} needs ${firstDefect}`, + owner: "Source data owner", + status: result.submission_blocked ? "open" : "waiting", + severity: result.submission_blocked ? "blocked" : "warning", + impact: event.blocking_defects?.[0]?.remediation || "Correct the source record and rerun CSV validation.", + source: event.traceability_lot_code || `row-${event.event_index + 1}`, + }); + }); + + result?.blocking_reasons.slice(0, 3).forEach((reason, index) => { + items.push({ + id: `blocking-${index}`, + title: reason, + owner: "Implementation", + status: "open", + severity: "blocked", + impact: "Commit gate stays closed until this blocker is resolved.", + source: "CSV evaluator", + }); + }); + + if (result?.duplicate_warnings?.length) { + items.push({ + id: "duplicate-risk", + title: `${result.duplicate_warnings.length} duplicate or idempotency risks need review`, + owner: "Integration owner", + status: "waiting", + severity: "warning", + impact: "Duplicate records can weaken audit trust and supplier feed quality.", + source: "CSV evaluator", + }); + } + + return items.slice(0, 8); +} + +function getLotFixGuidance(lot: { + missingCtes: string[]; + readiness: ReturnType; +}) { + if (lot.readiness.state === "blocked") { + return { + reason: "Delivery failed or no traceability events were received.", + owner: "Integration owner", + next: "Review delivery status, then replay the source.", + }; + } + + const lowerMissing = lot.missingCtes.join(" ").toLowerCase(); + if (lowerMissing.includes("shipping") || lowerMissing.includes("receiving")) { + return { + reason: `Missing handoff evidence for ${lot.missingCtes.join(", ")}.`, + owner: "Shipping or receiving source", + next: "Map the handoff fields or upload a corrected CSV.", + }; + } + + return { + reason: lot.missingCtes.length + ? `Missing source evidence for ${lot.missingCtes.join(", ")}.` + : "Delivery posted, but the lot is not test complete.", + owner: "Supplier data owner", + next: "Open the fix queue and rerun validation after correction.", + }; +} + +const standaloneTabs = ["Control room", "Data feeder", "Fix queue", "Scenarios", "Suppliers", "Lots", "Lineage", "Test previews", "Event log", "Diagnostics"]; +const dashboardTabs = ["Overview", "Data feeder", "Fix queue", "Lots", "Lineage", "Test previews", "Record log", "Diagnostics"]; + +const lotCodes = [ + "00614141000012-20260426-000001", + "00614141000012-20260426-000002", + "00614141000012-20260426-000003", +]; + +const events: TraceEvent[] = [ + { + id: 15, + cte: "receiving", + lotCode: lotCodes[0], + product: "Green Leaf Lettuce", + location: "Bay Area DC", + timestamp: "Apr 27, 2026, 2:04 AM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 14, + cte: "shipping", + lotCode: lotCodes[0], + product: "Green Leaf Lettuce", + location: "Salinas Packout Dock", + timestamp: "Apr 26, 2026, 10:41 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 13, + cte: "initial_packing", + lotCode: lotCodes[0], + product: "Green Leaf Lettuce", + location: "Salinas Packhouse", + timestamp: "Apr 26, 2026, 8:12 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 12, + cte: "cooling", + lotCode: lotCodes[0], + product: "Green Leaf Lettuce", + location: "Salinas Cooling Hub", + timestamp: "Apr 26, 2026, 6:12 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 11, + cte: "harvesting", + lotCode: lotCodes[0], + product: "Green Leaf Lettuce", + location: "Example Grower Co", + timestamp: "Apr 26, 2026, 3:20 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 10, + cte: "receiving", + lotCode: lotCodes[1], + product: "Spring Mix", + location: "Los Angeles DC", + timestamp: "Apr 27, 2026, 1:24 AM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 9, + cte: "shipping", + lotCode: lotCodes[1], + product: "Spring Mix", + location: "Imperial Packout Dock", + timestamp: "Apr 26, 2026, 10:03 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 8, + cte: "initial_packing", + lotCode: lotCodes[1], + product: "Spring Mix", + location: "Imperial Packhouse", + timestamp: "Apr 26, 2026, 7:18 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 7, + cte: "cooling", + lotCode: lotCodes[1], + product: "Spring Mix", + location: "Imperial Pre-Cool Facility", + timestamp: "Apr 26, 2026, 4:51 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 6, + cte: "harvesting", + lotCode: lotCodes[1], + product: "Spring Mix", + location: "Desert Bloom Farm", + timestamp: "Apr 26, 2026, 3:43 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 5, + cte: "cooling", + lotCode: lotCodes[2], + product: "Green Leaf Lettuce", + location: "Salinas Cooling Hub", + timestamp: "Apr 26, 2026, 5:26 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, + { + id: 4, + cte: "harvesting", + lotCode: lotCodes[2], + product: "Green Leaf Lettuce", + location: "Example Grower Co", + timestamp: "Apr 26, 2026, 4:28 PM", + mode: "simulation", + attempts: 1, + deliveryStatus: "posted", + }, +]; + +const lineageByLot: Record = { + [lotCodes[0]]: [ + { cte: "Harvesting", location: "Example Grower Co", time: "Apr 26, 3:20 PM", state: "complete" }, + { cte: "Cooling", location: "Salinas Cooling Hub", time: "Apr 26, 6:12 PM", state: "complete" }, + { cte: "Initial packing", location: "Salinas Packhouse", time: "Apr 26, 8:12 PM", state: "complete" }, + { cte: "Shipping", location: "Salinas Packout Dock", time: "Apr 26, 10:41 PM", state: "complete" }, + { cte: "DC receiving", location: "Bay Area DC", time: "Apr 27, 2:04 AM", state: "complete" }, + ], + [lotCodes[1]]: [ + { cte: "Harvesting", location: "Desert Bloom Farm", time: "Apr 26, 3:43 PM", state: "complete" }, + { cte: "Cooling", location: "Imperial Pre-Cool Facility", time: "Apr 26, 4:51 PM", state: "complete" }, + { cte: "Initial packing", location: "Imperial Packhouse", time: "Apr 26, 7:18 PM", state: "complete" }, + { cte: "Shipping", location: "Imperial Packout Dock", time: "Apr 26, 10:03 PM", state: "complete" }, + { cte: "DC receiving", location: "Los Angeles DC", time: "Apr 27, 1:24 AM", state: "complete" }, + ], + [lotCodes[2]]: [ + { cte: "Harvesting", location: "Example Grower Co", time: "Apr 26, 4:28 PM", state: "complete" }, + { cte: "Cooling", location: "Salinas Cooling Hub", time: "Apr 26, 5:26 PM", state: "complete" }, + { cte: "Initial packing", location: "Not captured", time: "Missing KDEs", state: "missing" }, + { cte: "Shipping", location: "Not captured", time: "Missing CTE", state: "missing" }, + { cte: "DC receiving", location: "Not captured", time: "Missing CTE", state: "missing" }, + ], +}; + +const stageIndex: Record = { + loaded: 0, + generating: 1, + delivering: 2, + validating: 3, + complete: 4, + exported: 5, +}; + +function getEventStatus(stage: RunStage): EventStatus { + if (stage === "generating") return "queued"; + if (stage === "validating") return "validated"; + return "posted"; +} + +function StatusPill({ status }: { status: EventStatus }) { + const styles = { + posted: "border-emerald-200 bg-emerald-50 text-emerald-700", + validated: "border-blue-200 bg-blue-50 text-blue-700", + queued: "border-amber-200 bg-amber-50 text-amber-700", + generated: "border-amber-200 bg-amber-50 text-amber-700", + failed: "border-amber-200 bg-amber-50 text-amber-800", + }; + + return ( + + {status} + + ); +} + +function LotReadinessPill({ state, label }: { state: LotReadinessState; label: string }) { + const styles = { + ready: "border-emerald-200 bg-emerald-50 text-emerald-700", + warning: "border-amber-200 bg-amber-50 text-amber-700", + blocked: "border-amber-200 bg-amber-50 text-amber-800", + }; + + return ( + + {label} + + ); +} + +function LineageStatusIcon({ state }: { state: CteState }) { + if (state === "complete") return ; + return ; +} + +type InflowLabClientProps = { + mode?: "standalone" | "dashboard"; + /** When false, CSV evaluate stays disabled (experimental evaluator not in this deploy). */ + csvEvaluateEnabled?: boolean; +}; + +export function InflowLabClient({ + mode = "standalone", + csvEvaluateEnabled = true, +}: InflowLabClientProps) { + const { isAuthenticated, isHydrated } = useAuth(); + const { tenantId: activeTenantId } = useTenant(); + const isStandalone = mode === "standalone"; + const primaryTab = isStandalone ? "Control room" : "Overview"; + const tabs = isStandalone ? standaloneTabs : dashboardTabs; + const [activeTab, setActiveTab] = useState(primaryTab); + const [isTutorialOpen, setIsTutorialOpen] = useState(false); + const [selectedLot, setSelectedLot] = useState(lotCodes[0]); + const [traceInput, setTraceInput] = useState(lotCodes[0]); + const [runStage, setRunStage] = useState("complete"); + const [health, setHealth] = useState(null); + const [serviceStatus, setServiceStatus] = useState(null); + const [serviceEvents, setServiceEvents] = useState([]); + const [serviceLineage, setServiceLineage] = useState>({}); + const [serviceError, setServiceError] = useState(null); + const [isBusy, setIsBusy] = useState(false); + const [tenantId, setTenantId] = useState("mock-tenant"); + const [scenarioPreset, setScenarioPreset] = useState("leafy_greens_supplier"); + const [fixture, setFixture] = useState("leafy_greens_trace"); + const [deliveryMode, setDeliveryMode] = useState("mock"); + const [source, setSource] = useState("codex-simulator"); + const [exportPreset, setExportPreset] = useState("all_records"); + const [startDate, setStartDate] = useState("2026-04-26"); + const [endDate, setEndDate] = useState("2026-04-27"); + const [feederCsv, setFeederCsv] = useState(FEEDER_SAMPLE_CSV); + const [feederResult, setFeederResult] = useState(null); + const [feederError, setFeederError] = useState(null); + const [feederSavedAt, setFeederSavedAt] = useState(null); + const [isFeederEvaluating, setIsFeederEvaluating] = useState(false); + const [activeScenarioId, setActiveScenarioId] = useState(scenarioLibrary[0].id); + const [workbenchScenarios, setWorkbenchScenarios] = useState(scenarioLibrary); + const [backendReadiness, setBackendReadiness] = useState(null); + const [backendFixQueue, setBackendFixQueue] = useState([]); + const [commitGateDecision, setCommitGateDecision] = useState(null); + const [workbenchRunId, setWorkbenchRunId] = useState(null); + const [workbenchError, setWorkbenchError] = useState(null); + const feederFileInputRef = useRef(null); + // Bumped whenever the CSV input changes so a late evaluate response cannot + // overwrite the current feederCsv / saved-run pairing. + const feederInputRevisionRef = useRef(0); + + useEffect(() => { + if (!isHydrated || !isAuthenticated || !activeTenantId) { + return; + } + + setTenantId((currentTenantId) => + currentTenantId === activeTenantId ? currentTenantId : activeTenantId + ); + }, [activeTenantId, isAuthenticated, isHydrated]); + + const refreshService = async () => { + const [healthPayload, statusPayload, eventsPayload] = await Promise.all([ + inflowJson("/api/healthz"), + inflowJson("/api/simulate/status"), + inflowJson("/api/events?limit=100"), + ]); + const nextEvents = (eventsPayload.events || []).map(toTraceEvent); + setHealth(healthPayload); + setServiceStatus(statusPayload); + setServiceEvents(nextEvents); + setServiceError(optionalOfflineDetail(healthPayload, statusPayload, eventsPayload)); + + const firstLot = nextEvents[0]?.lotCode; + if (firstLot && (!traceInput || traceInput === lotCodes[0])) { + setSelectedLot(firstLot); + setTraceInput(firstLot); + } + }; + + const selectedEvents = useMemo( + () => (serviceEvents.length ? serviceEvents : events).filter((event) => event.lotCode === selectedLot), + [selectedLot, serviceEvents] + ); + + const activeEvents = serviceEvents.length ? serviceEvents : events; + const activeLineageByLot = serviceEvents.length ? serviceLineage : lineageByLot; + const selectedLineage = activeLineageByLot[selectedLot] ?? toLineage(selectedEvents); + const selectedReadiness = getLotReadiness(selectedLineage, selectedEvents); + const selectedCompleteCount = selectedReadiness.completeCount; + const selectedIsExportReady = selectedReadiness.exportReady; + const hasGeneratedRecords = runStage !== "loaded"; + const visibleEvents = hasGeneratedRecords ? activeEvents : []; + const visibleEventStatus = serviceEvents.length ? "posted" : getEventStatus(runStage); + const localDeliveryCounts = useMemo( + () => + activeEvents.reduce( + (counts, event) => { + counts.attempts += event.attempts; + if (event.deliveryStatus === "posted") counts.posted += 1; + if (event.deliveryStatus === "failed") counts.failed += 1; + if (event.deliveryStatus === "generated") counts.generated += 1; + return counts; + }, + { posted: 0, failed: 0, generated: 0, attempts: 0 } + ), + [activeEvents] + ); + const deliveredCount = serviceStatus?.stats?.delivery?.posted ?? localDeliveryCounts.posted; + + useEffect(() => { + if (isStandalone) { + document.body.dataset.inflowLab = "true"; + } + refreshService().catch((error) => setServiceError(error instanceof Error ? error.message : "Inflow Lab service unavailable")); + return () => { + if (isStandalone) { + delete document.body.dataset.inflowLab; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isStandalone]); + + useEffect(() => { + if (!isHydrated) { + return; + } + + if (!isAuthenticated) { + setWorkbenchScenarios(scenarioLibrary); + return; + } + + workbenchJson("/scenarios") + .then((scenarios) => { + if (scenarios.length) { + setWorkbenchScenarios(scenarios); + return; + } + setWorkbenchScenarios(scenarioLibrary); + }) + .catch(() => { + setWorkbenchScenarios(scenarioLibrary); + }); + }, [isAuthenticated, isHydrated]); + + useEffect(() => { + if (!serviceEvents.length || !selectedLot || serviceLineage[selectedLot]) return; + + inflowJson(`/api/lineage/${encodeURIComponent(selectedLot)}`) + .then((payload) => { + const lineageEvents = (payload.records || []).map(toTraceEvent); + setServiceLineage((current) => ({ ...current, [selectedLot]: toLineage(lineageEvents) })); + }) + .catch((error) => setServiceError(error instanceof Error ? error.message : "Could not trace lot")); + }, [selectedLot, serviceEvents.length, serviceLineage]); + + const runStatusLabel = + runStage === "exported" + ? "Mock export filters prepared" + : runStage === "complete" + ? "Mock run complete" + : runStage === "loaded" + ? "Mock scenario loaded" + : "Mock run in progress"; + + const uniqueLots = useMemo( + () => + Array.from(new Set(activeEvents.map((event) => event.lotCode))).map((lotCode) => { + const lotEvents = activeEvents.filter((event) => event.lotCode === lotCode); + const lineage = activeLineageByLot[lotCode] ?? toLineage(lotEvents); + const product = lotEvents[0]?.product ?? "Unknown product"; + const readiness = getLotReadiness(lineage, lotEvents); + return { + lotCode, + product, + events: lotEvents.length, + readiness, + missingCtes: readiness.missingCtes, + completeCount: readiness.completeCount, + totalCount: readiness.totalCount, + lastLocation: lotEvents[0]?.location ?? "No location", + }; + }), + [activeEvents, activeLineageByLot] + ); + const exportReadyLots = uniqueLots.filter((lot) => lot.readiness.exportReady); + const exceptionLots = uniqueLots.filter((lot) => !lot.readiness.exportReady); + const warningLots = uniqueLots.filter((lot) => lot.readiness.state === "warning"); + const blockedLots = uniqueLots.filter((lot) => lot.readiness.state === "blocked"); + + const loadScenario = async () => { + setIsBusy(true); + try { + await inflowJson(`/api/demo-fixtures/${fixture}/load`, { + method: "POST", + body: JSON.stringify({ + reset: true, + source, + delivery: { mode: deliveryMode, tenant_id: tenantId || null }, + }), + }); + await refreshService(); + setRunStage("complete"); + } catch (error) { + setServiceError(error instanceof Error ? error.message : "Could not load Inflow Lab fixture"); + setRunStage("loaded"); + } finally { + setIsBusy(false); + } + setActiveTab(primaryTab); + }; + + const runPipeline = async () => { + trackToolRunStarted("inflow-lab"); + setIsBusy(true); + setRunStage("generating"); + setActiveTab(primaryTab); + try { + await inflowJson("/api/simulate/start", { + method: "POST", + body: JSON.stringify({ + config: { + source, + scenario: scenarioPreset, + interval_seconds: 0.1, + batch_size: 1, + persist_path: "data/events.jsonl", + delivery: { mode: deliveryMode, tenant_id: tenantId || null }, + }, + }), + }); + setRunStage("delivering"); + await new Promise((resolve) => window.setTimeout(resolve, 700)); + await inflowJson("/api/simulate/stop", { method: "POST" }); + await refreshService(); + setRunStage("complete"); + trackToolRunCompleted("inflow-lab", "success"); + } catch (error) { + setServiceError(error instanceof Error ? error.message : "Could not run Inflow Lab pipeline"); + setRunStage("loaded"); + trackToolRunCompleted("inflow-lab", "error"); + } finally { + setIsBusy(false); + } + }; + + const resetServiceState = async () => { + setIsBusy(true); + try { + await inflowJson("/api/simulate/reset", { + method: "POST", + body: JSON.stringify({ + source, + scenario: scenarioPreset, + delivery: { mode: deliveryMode, tenant_id: tenantId || null }, + }), + }); + setServiceEvents([]); + setServiceLineage({}); + await refreshService(); + setRunStage("loaded"); + } catch (error) { + setServiceError(error instanceof Error ? error.message : "Could not reset Inflow Lab state"); + } finally { + setIsBusy(false); + } + }; + + const evaluateFeederData = async () => { + if (!csvEvaluateEnabled) { + setFeederError("CSV evaluation needs experimental features enabled on this deployment."); + return; + } + if (!feederCsv.trim()) { + setFeederError("Paste CSV text or upload a CSV file before evaluating."); + return; + } + + trackToolRunStarted("inflow-lab"); + setIsFeederEvaluating(true); + setFeederError(null); + const evaluationRevision = feederInputRevisionRef.current; + try { + const response = await fetchWithCsrf("/api/ingestion/api/v1/sandbox/evaluate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ csv: feederCsv }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const detail = typeof payload.detail === "string" ? payload.detail : `CSV evaluation failed: ${response.status}`; + throw new Error(detail); + } + + const result = (await response.json()) as SandboxEvaluationResult; + if (evaluationRevision !== feederInputRevisionRef.current) { + // CSV changed while this request was in flight — drop the stale result. + return; + } + const evaluatedEvents = result.events.map(sandboxEventToTraceEvent); + setFeederResult(result); + setFeederSavedAt(null); + setWorkbenchRunId(null); + setWorkbenchError(null); + setServiceEvents(evaluatedEvents); + setServiceLineage(lineageByLotFromEvents(evaluatedEvents)); + setServiceStatus(null); + setRunStage("complete"); + setSource("sandbox-data-feeder"); + + const firstLot = evaluatedEvents[0]?.lotCode; + if (firstLot) { + setSelectedLot(firstLot); + setTraceInput(firstLot); + } + + try { + const [readiness, gate] = await Promise.all([ + workbenchJson("/readiness/preview", { + method: "POST", + body: JSON.stringify(result), + }), + workbenchJson("/commit-gate", { + method: "POST", + body: JSON.stringify({ + mode: "preflight", + tenant_id: tenantId || "default", + result, + authenticated: false, + persisted: false, + provenance_attached: false, + }), + }), + ]); + // Re-check after the workbench round-trip — a scenario load / CSV edit + // during preview must not paint the previous run's score or gate. + if (evaluationRevision !== feederInputRevisionRef.current) { + return; + } + setBackendReadiness(typeof readiness.score === "number" ? readiness : null); + setCommitGateDecision(Array.isArray(gate.reasons) ? gate : null); + setBackendFixQueue([]); + } catch (workbenchIssue) { + if (evaluationRevision !== feederInputRevisionRef.current) { + return; + } + setBackendReadiness(null); + setCommitGateDecision(null); + setWorkbenchError( + workbenchIssue instanceof Error + ? `Workbench preview unavailable: ${workbenchIssue.message}` + : "Workbench preview unavailable" + ); + } + trackToolRunCompleted("inflow-lab", "success"); + } catch (error) { + // Failed evaluate must honor the same revision guard as success — + // otherwise a late 4xx/network error from the previous CSV reappears + // after the user already edited, uploaded, or loaded another scenario. + if (evaluationRevision !== feederInputRevisionRef.current) { + return; + } + setFeederError(error instanceof Error ? error.message : "Could not evaluate pasted data"); + trackToolRunCompleted("inflow-lab", "error"); + } finally { + setIsFeederEvaluating(false); + } + }; + + const handleFeederFile = (file: File | null) => { + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + feederInputRevisionRef.current += 1; + setFeederCsv(String(reader.result || "")); + setFeederResult(null); + setFeederSavedAt(null); + setFeederError(null); + setWorkbenchError(null); + setBackendReadiness(null); + setBackendFixQueue([]); + setCommitGateDecision(null); + setWorkbenchRunId(null); + }; + reader.onerror = () => setFeederError("Could not read the selected CSV file."); + reader.readAsText(file); + }; + + const loadFeederScenario = (scenarioId: string) => { + const scenario = workbenchScenarios.find((item) => item.id === scenarioId) || scenarioLibrary.find((item) => item.id === scenarioId); + if (!scenario) return; + setActiveScenarioId(scenario.id); + feederInputRevisionRef.current += 1; + setFeederCsv(scenario.csv); + setFeederResult(null); + setFeederSavedAt(null); + setFeederError(null); + setWorkbenchError(null); + setBackendReadiness(null); + setBackendFixQueue([]); + setCommitGateDecision(null); + setWorkbenchRunId(null); + setActiveTab("Data feeder"); + }; + + const saveFeederRun = async () => { + if (!feederResult) return; + const savedAt = new Date().toISOString(); + window.localStorage.setItem( + FEEDER_SAVED_RUN_KEY, + JSON.stringify({ + saved_at: savedAt, + csv: feederCsv, + result: feederResult, + source: "inflow-lab-data-feeder", + }) + ); + setFeederSavedAt(savedAt); + setWorkbenchError(null); + + try { + const run = await workbenchJson("/runs", { + method: "POST", + body: JSON.stringify({ + tenant_id: tenantId || "default", + source: "inflow-lab-data-feeder", + csv: feederCsv, + result: feederResult, + }), + }); + if (run.run_id) { + setWorkbenchRunId(run.run_id); + } + if (run.readiness && typeof run.readiness.score === "number") { + setBackendReadiness(run.readiness); + } + if (Array.isArray(run.fix_queue)) { + setBackendFixQueue(run.fix_queue); + } + if (run.commit_gate && Array.isArray(run.commit_gate.reasons)) { + setCommitGateDecision(run.commit_gate); + } + if (run.saved_at) { + setFeederSavedAt(run.saved_at); + } + } catch (error) { + setWorkbenchError( + error instanceof Error + ? `Saved locally; backend workbench save unavailable: ${error.message}` + : "Saved locally; backend workbench save unavailable" + ); + } + }; + + const traceLot = async () => { + if (traceInput) { + setSelectedLot(traceInput); + setActiveTab("Lineage"); + } + try { + const payload = await inflowJson(`/api/lineage/${encodeURIComponent(traceInput)}`); + const lineageEvents = (payload.records || []).map(toTraceEvent); + setServiceLineage((current) => ({ ...current, [traceInput]: toLineage(lineageEvents) })); + setServiceError(null); + } catch (error) { + setServiceError(error instanceof Error ? error.message : "Could not trace lot"); + } + }; + + const traceLatestLot = () => { + const latestLot = visibleEvents[0]?.lotCode || lotCodes[0]; + setSelectedLot(latestLot); + setTraceInput(latestLot); + setActiveTab("Lineage"); + }; + + const prepareExport = () => { + setRunStage("exported"); + setActiveTab("Test previews"); + }; + + const startMachineDemo = async () => { + if (visibleEvents.length === 0) { + await loadScenario(); + } + await runPipeline(); + setActiveTab(isStandalone ? "Event log" : "Record log"); + }; + + // Rebuild steps each render (avoids react-hooks/refs on a demo callback). + // Tutorial measures against currentStep + step.target, not step object + // identity, so a fresh steps array no longer re-fires scrollIntoView. + const tutorialSteps = buildInflowLabClientTutorialSteps({ + runDemo: () => { + void startMachineDemo(); + }, + isStandalone, + }); + + const startTutorial = () => { + // Reset to the primary tab so every step's anchor is where the copy + // says it is, regardless of where the user wandered beforehand. + setActiveTab(primaryTab); + setIsTutorialOpen(true); + }; + + const csvExportHref = safeHref( + servicePath( + `/api/regengine/export/fda-request?preset=${encodeURIComponent(exportPreset)}&traceability_lot_code=${encodeURIComponent(traceInput)}&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`, + ), + ); + const epcisExportHref = safeHref( + servicePath( + `/api/regengine/export/epcis?traceability_lot_code=${encodeURIComponent(traceInput)}&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`, + ), + ); + const engineConnected = Boolean(health?.ok && !serviceError); + const connectionLabel = engineConnected ? "Connection ready" : serviceError ? "Connection needs attention" : "Checking connection"; + const connectionTone = engineConnected ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-800"; + const postedCount = serviceStatus?.stats?.delivery?.posted ?? deliveredCount; + const failedCount = serviceStatus?.stats?.delivery?.failed ?? localDeliveryCounts.failed; + const generatedOnlyCount = serviceStatus?.stats?.delivery?.generated ?? localDeliveryCounts.generated; + const attemptCount = serviceStatus?.stats?.delivery?.attempts ?? localDeliveryCounts.attempts; + const totalLotCount = uniqueLots.length; + const exceptionCount = exceptionLots.length + failedCount; + const feederDefectCount = feederResult ? feederResult.total_kde_errors + feederResult.total_rule_failures : 0; + const validationOutcome = feederResult + ? feederDefectCount + ? `${feederDefectCount} validation issue${feederDefectCount === 1 ? "" : "s"} found` + : "No CSV validation issues" + : exceptionCount + ? `${exceptionCount} exception${exceptionCount === 1 ? "" : "s"} to review` + : "No blocking exceptions"; + const exportReadyCount = exportReadyLots.length; + const exportReadinessLabel = + totalLotCount > 0 ? `${exportReadyCount} of ${totalLotCount} lots test complete` : "No test records loaded"; + const lastReceivedLabel = serviceStatus?.stats?.delivery?.last_success_at + ? formatServiceTime(serviceStatus.stats.delivery.last_success_at) + : visibleEvents[0]?.timestamp || "No inbound records yet"; + const feederCteCoverage = useMemo(() => { + const covered = new Set((feederResult?.events || []).map((event) => event.cte_type).filter(Boolean)); + return { + covered: SANDBOX_CTE_TYPES.filter((cte) => covered.has(cte)), + missing: SANDBOX_CTE_TYPES.filter((cte) => !covered.has(cte)), + }; + }, [feederResult]); + const feederDiagnosis = useMemo(() => (feederResult ? summarizeFeederDiagnosis(feederResult) : null), [feederResult]); + const feederRemediationPlan = useMemo(() => (feederResult ? buildFeederRemediationPlan(feederResult) : []), [feederResult]); + const readinessScore = useMemo(() => { + if (backendReadiness) return backendReadiness.score; + if (totalLotCount === 0) return 0; + const lotCoverage = exportReadyLots.length / totalLotCount; + const selectedCoverage = selectedLineage.length ? selectedCompleteCount / selectedLineage.length : 0; + const deliveryTotal = Math.max(1, postedCount + failedCount + generatedOnlyCount); + const deliveryQuality = postedCount / deliveryTotal; + const feederQuality = feederResult + ? feederResult.total_events > 0 + ? feederResult.compliant_events / feederResult.total_events + : 0 + : exceptionCount + ? 0.72 + : 1; + + return Math.max( + 0, + Math.min( + 100, + Math.round(lotCoverage * 36 + selectedCoverage * 18 + deliveryQuality * 22 + feederQuality * 18 + (engineConnected ? 6 : 0)) + ) + ); + }, [ + engineConnected, + backendReadiness, + exceptionCount, + exportReadyLots.length, + failedCount, + feederResult, + generatedOnlyCount, + postedCount, + selectedCompleteCount, + selectedLineage.length, + totalLotCount, + ]); + const fixQueue = useMemo( + () => (backendFixQueue.length ? backendFixQueue : buildFixQueue(feederResult, uniqueLots)), + [backendFixQueue, feederResult, uniqueLots] + ); + const unresolvedFixCount = fixQueue.filter((item) => item.status === "open" || item.status === "waiting").length; + const supplierReadiness = [ + { + name: "Example Grower Co", + status: exportReadyCount > 0 ? "Passing validation" : "Fixture received", + score: Math.max(58, readinessScore - 3), + blocker: exceptionLots.length ? "Waiting on missing CTE handoffs" : "No open blockers", + }, + { + name: "FreshPack Central", + status: feederResult ? (feederResult.submission_blocked ? "Fixture received" : "Mapping configured") : "Invited", + score: feederResult ? Math.max(35, Math.round((feederResult.compliant_events / Math.max(1, feederResult.total_events)) * 100)) : 44, + blocker: feederResult?.submission_blocked ? "CSV blockers need correction" : "Mapping profile not promoted", + }, + { + name: "Bay Area DC", + status: selectedIsExportReady ? "Export ready" : "Production feed active", + score: Math.min(98, readinessScore + 5), + blocker: selectedIsExportReady ? "No open blockers" : "Selected lot needs complete lineage", + }, + ]; + const supplierAverageScore = Math.round( + supplierReadiness.reduce((total, supplier) => total + supplier.score, 0) / Math.max(1, supplierReadiness.length) + ); + const activeDataBoundary = feederResult ? "Uploaded CSV check run" : serviceEvents.length ? "Mock feed data" : "Bundled mock fixture"; + const boundaryStateDetail = feederResult + ? "You are viewing uploaded CSV data checked in this browser session." + : serviceEvents.length + ? "You are viewing mock feed data; no uploaded CSV check run has been evaluated yet." + : "You are viewing bundled fixture data that is safe for exploration and walkthroughs."; + const boundarySteps = [ + { + label: "CSV check", + value: feederResult ? "Uploaded CSV was checked" : "No uploaded CSV run yet", + tone: "border-blue-200 bg-blue-50 text-blue-900", + }, + { + label: "Mock Inflow Lab", + value: "Test records only; not production ingestion", + tone: "border-amber-200 bg-amber-50 text-amber-950", + }, + { + label: "Authenticated feed", + value: "Starts after signed-in import mapping", + tone: "border-slate-200 bg-white text-slate-900", + }, + { + label: "Production evidence", + value: "Formal production records come from signed-in imports", + tone: "border-emerald-200 bg-emerald-50 text-emerald-950", + }, + ]; + const machineDemoSteps = [ + { + label: "Load test records", + detail: visibleEvents.length > 0 ? `${visibleEvents.length} test records are loaded` : "Seed the mock leafy-greens feed", + state: visibleEvents.length > 0 ? "complete" : "active", + }, + { + label: "Validate records", + detail: runStage === "generating" || runStage === "delivering" ? "Inbound records are moving" : validationOutcome, + state: runStage === "loaded" ? "pending" : runStage === "generating" || runStage === "delivering" ? "active" : "complete", + }, + { + label: "Review exceptions", + detail: exceptionCount ? `${exceptionCount} exception${exceptionCount === 1 ? "" : "s"} to review` : "No exceptions in this test run", + state: postedCount > 0 ? "complete" : "pending", + }, + { + label: "Trace selected lot", + detail: selectedReadiness.exportReady + ? "Selected lot has a complete test chain" + : "Selected lot shows the missing handoffs", + state: selectedLot ? "complete" : "pending", + }, + { + label: "Preview test handoff", + detail: "Test filters only; formal production records stay in production exports", + state: runStage === "exported" ? "complete" : "pending", + }, + ]; + + return ( +
+
+ {isStandalone ? ( +
+
+
+
+
+

+ RegEngine Inflow Lab +

+ + {runStatusLabel} + +
+

+ Diagnose CSV data, run a mock FSMA 204 feed, and keep test records separate from signed-in production imports. +

+
+
+ + + + + +
+
+ +
+
+
+ Tenant + {tenantId} +
+
+ Delivery + {deliveryMode === "mock" ? "Mock only" : "Live adapter disabled"} +
+
+ Fixture + {fixture === "leafy_greens_trace" ? "Leafy greens trace" : "Fresh-cut transformation"} +
+
+ Source + {source} +
+
+
+
+
+ ) : ( +
+
+
+
+

Inflow Lab

+ {connectionLabel} + Mock environment +
+

+ Test supplier traceability data before production import. This lab uses mock or uploaded test records only; formal production records start after signed-in import. +

+
+ + +
+
+
+
+ Last inbound record + {lastReceivedLabel} +
+
+ Readiness score + {readinessScore}/100 +
+
+ Validation outcome + {validationOutcome} +
+
+ Test completeness + {exportReadinessLabel} +
+
+
+
+ )} + + + +
+
+
+
+ Boundary active + {activeDataBoundary} +
+

+ {boundaryStateDetail} The lab checks Critical Tracking Event (CTE) handoffs and required Key Data Element (KDE) fields, but it cannot create formal production records. Your operation remains responsible for compliance. +

+
+
+ {boundarySteps.map((step) => ( +
+

{step.label}

+

{step.value}

+
+ ))} +
+
+
+ + {serviceError && ( +
+ Inflow Lab service connection: {serviceError} +
+ )} + +
+ + +
+
+ + {isStandalone ? "Operational workspace" : "Inbound data readiness"} + +

+ {isStandalone + ? "Review mock-generated records, trace lots, and preview test filters without mixing simulator data into production imports." + : "Validate FSMA 204 records, resolve exceptions, prove traceability, and keep formal production records separate from fixture data."} +

+
+
+ setTraceInput(event.target.value)} + className={cn("min-w-0 border-slate-300 text-xs sm:w-[290px]", isStandalone ? "h-10" : "h-9")} + aria-label="Traceability lot code" + /> + +
+
+
+ +
+
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === "Overview" && ( +
+
+
+
+ +

Connection status

+
+

{connectionLabel}

+

+ Test submissions are routed through the Inflow Lab gateway and remain outside production ingestion. +

+
+ +
+
+ +

Traceability Readiness Score

+
+

{readinessScore}/100

+

+ Blends KDE completeness, CTE lifecycle coverage, delivery quality, CSV check pass rate, and connection health. +

+
+ +
+
+ {exceptionCount ? ( + + ) : ( + + )} +

Validation outcome

+
+

{validationOutcome}

+

+ Ready: {exportReadyCount}. Warning: {warningLots.length}. Blocked: {blockedLots.length}. Records need CTE coverage, KDE completeness, lineage, and posted delivery before export. +

+
+ +
+
+ +

Test completeness

+
+

{exportReadinessLabel}

+

+ Test previews use the selected lot and date window. Formal production records come later from signed-in imports. +

+
+
+ +
+
+
+
+

Lot readiness

+

Select a lot to inspect the traceability path and required FSMA 204 events.

+
+ +
+
+ {uniqueLots.map((lot) => ( + + ))} +
+
+ +
+
+
+
+

Exceptions

+

+ Partial lots remain visible for remediation instead of being hidden from the mock preview. +

+
+ +
+
+ {exceptionLots.length === 0 && failedCount === 0 ? ( +
+ No blocking exceptions found in the current fixture data. +
+ ) : ( + <> + {exceptionLots.map((lot) => { + const guidance = getLotFixGuidance(lot); + + return ( +
+

{lot.product}

+

{lot.lotCode}

+

{guidance.reason}

+
+ Likely owner: {guidance.owner} + Next: {guidance.next} +
+ +
+ ); + })} + {failedCount > 0 && ( +
+ {failedCount} inbound delivery {failedCount === 1 ? "attempt needs" : "attempts need"} review. +
+ )} + + )} +
+
+ +
+

Test handoff preview

+

+ Preview test CSV or EPCIS JSON-LD filters. Formal production records are generated only from imported production data. +

+ +
+
+
+
+ )} + + {activeTab === "Data feeder" && ( +
+
+
+
+

Paste or upload inbound CSV

+

+ Uses the public stateless CSV evaluator. Data is parsed, normalized, and checked against FSMA 204 rules without being stored or promoted to production ingestion. +

+
+
+ handleFeederFile(event.target.files?.[0] ?? null)} + /> + +
+
+