"use client"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { FileUploadZone } from "@/components/upload/file-upload-zone"; import { Plus, Upload, Loader2, ChevronRight, ChevronLeft, Cpu, AlertCircle, X, CheckCircle2, Zap, ExternalLink, RotateCw, } from "lucide-react"; import { authEnabled } from "@/lib/auth"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { createProject, deleteProject, uploadBom, uploadNetlist, uploadDatasheet, startPipeline, checkLibrary, fetchAutoDatasheet, autoResolveSimple, resolveLcscPassive, DatasheetFetchError, LcscResolveError, reopenProject, renameProject, downloadProjectBom, downloadProjectNetlist, fetchProject, fetchProjectDatasheets, fetchPipelineEstimate, fetchCredits, safeMpn, updateNetlistSubdesigns, } from "@/lib/api"; import type { CostEstimate, EdifSubDesign, LcscPayload, NetlistPreviewDesignator, Project, } from "@/lib/types"; import Link from "next/link"; import * as XLSX from "xlsx"; // --------------------------------------------------------------------------- // CSV / XLSX parsing (client-side, for column detection + IC classification) // --------------------------------------------------------------------------- function parseCsvLine(line: string): string[] { const fields: string[] = []; let current = ""; let inQuotes = false; for (const ch of line) { if (ch === '"') { inQuotes = !inQuotes; } else if (ch === "," && !inQuotes) { fields.push(current.trim()); current = ""; } else if (ch !== "\r") { current += ch; } } fields.push(current.trim()); return fields; } function parseCsv(text: string) { const lines = text.split("\n").filter((l) => l.trim()); if (lines.length === 0) return { headers: [] as string[], rows: [] as Record[] }; const headers = parseCsvLine(lines[0]); const rows = lines.slice(1).map((line) => { const values = parseCsvLine(line); const row: Record = {}; headers.forEach((h, i) => { row[h] = values[i] || ""; }); return row; }); return { headers, rows }; } function parseXlsx(buffer: ArrayBuffer) { const wb = XLSX.read(buffer, { type: "array" }); const ws = wb.Sheets[wb.SheetNames[0]]; const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" }); if (raw.length === 0) return { headers: [] as string[], rows: [] as Record[] }; const headers = raw[0].map(String); const rows = raw.slice(1).map((r) => { const row: Record = {}; headers.forEach((h, i) => { row[h] = r[i] != null ? String(r[i]) : ""; }); return row; }); return { headers, rows }; } // --------------------------------------------------------------------------- // Column auto-detection // --------------------------------------------------------------------------- const REF_CANDIDATES = ["Reference", "Designator", "Ref Des", "RefDes", "Ref"]; const MPN_CANDIDATES = [ "Manufacturer Part Number", "MPN", "Part Number", "Mfr Part", "Mfg Part Number", ]; function autoDetect(headers: string[], candidates: string[]): string | null { for (const c of candidates) { if (headers.includes(c)) return c; } const lower = candidates.map((c) => c.toLowerCase()); for (const h of headers) { if (lower.includes(h.toLowerCase())) return h; } return null; } // --------------------------------------------------------------------------- // BOM classification — mirrors pinscopex/taxonomy.py REF_PREFIX_TO_TYPE // --------------------------------------------------------------------------- interface IcMpnEntry { mpn: string; refs: string[]; } interface PassiveGroup { prefix: string; label: string; mpns: { mpn: string; refs: string[] }[]; } interface SimpleGroup { prefix: string; label: string; mpns: { mpn: string; refs: string[]; refPrefix: string }[]; } const PASSIVE_LABELS: Record = { R: "Resistors", C: "Capacitors", L: "Inductors", FB: "Ferrite Beads", }; const SIMPLE_LABELS: Record = { D: "Discrete Semiconductors", Q: "Discrete Semiconductors", LED: "Discrete Semiconductors", J: "Connectors", X: "Crystals", Y: "Crystals", T: "Transformers", F: "Fuses", SW: "Switches", }; const SIMPLE_PREFIXES = new Set(Object.keys(SIMPLE_LABELS)); const SIMPLE_PREFIX_TO_TYPE: Record = { D: "discrete", Q: "discrete", LED: "discrete", J: "connector", X: "crystal", Y: "crystal", T: "transformer", F: "fuse", SW: "switch", }; /** * Extract a series prefix from an MPN for datasheet auto-sharing. * * Includes leading alpha chars, an optional dash, then up to 4 alphanumeric * series-code characters. This distinguishes series like ERJ-2RKF vs * ERJ-3RQF vs ERJ-U02 that share leading letters but need different * datasheets. * * Examples: * ERJ-2RKF10R0X → ERJ-2RKF * ERJ-3RQF2R2V → ERJ-3RQF * ERJ-U02F6R20X → ERJ-U02F * GRM155R60J475 → GRM155R * 04023D104KAT2A → 0402 (digit-leading MPNs) */ function getSeriesPrefix(mpn: string): string { const m = mpn.match(/^[A-Za-z]+-?[A-Za-z0-9]{1,4}/); return (m?.[0] || mpn.slice(0, 4)).toUpperCase(); } function classifyBom( rows: Record[], refCol: string, mpnCol: string, ) { const icMap = new Map(); const passiveMap = new Map(); const simpleMap = new Map(); let passiveCount = 0; let totalRefs = 0; for (const row of rows) { const refsRaw = row[refCol] || ""; const value = (row.Value || row.Comment || "").trim(); const mpnFromCol = (row[mpnCol] || "").trim(); const refs = refsRaw .split(",") .map((r) => r.trim()) .filter(Boolean); const hasIc = refs.some((r) => /^U\d/i.test(r)); const mpn = mpnFromCol || (hasIc ? value : ""); for (const ref of refs) { totalRefs++; const prefix = (ref.match(/^[A-Za-z]+/)?.[0] || "").toUpperCase(); if (!mpn) continue; if (prefix === "U") { const existing = icMap.get(mpn) || []; existing.push(ref); icMap.set(mpn, existing); } else if (["R", "C", "L", "FB"].includes(prefix)) { passiveCount++; const entry = passiveMap.get(mpn) || { refs: [], prefix }; entry.refs.push(ref); passiveMap.set(mpn, entry); } else if (SIMPLE_PREFIXES.has(prefix)) { const entry = simpleMap.get(mpn) || { refs: [], prefix }; entry.refs.push(ref); simpleMap.set(mpn, entry); } } } const icMpns: IcMpnEntry[] = Array.from(icMap.entries()) .map(([mpn, refs]) => ({ mpn, refs: refs.sort() })) .sort((a, b) => a.mpn.localeCompare(b.mpn)); // Group passive MPNs by component type prefix const groupMap = new Map(); for (const [mpn, { refs, prefix }] of passiveMap.entries()) { const group = groupMap.get(prefix) || []; group.push({ mpn, refs: refs.sort() }); groupMap.set(prefix, group); } const passiveGroups: PassiveGroup[] = Array.from(groupMap.entries()) .map(([prefix, mpns]) => ({ prefix, label: PASSIVE_LABELS[prefix] || prefix, mpns: mpns.sort((a, b) => a.mpn.localeCompare(b.mpn)), })) .sort((a, b) => a.prefix.localeCompare(b.prefix)); // Group simple component MPNs by label (merge D/Q/LED into one group, etc.) const simpleLabelMap = new Map(); for (const [mpn, { refs, prefix }] of simpleMap.entries()) { const label = SIMPLE_LABELS[prefix] || prefix; const group = simpleLabelMap.get(label) || []; group.push({ mpn, refs: refs.sort(), refPrefix: prefix }); simpleLabelMap.set(label, group); } const simpleGroups: SimpleGroup[] = Array.from(simpleLabelMap.entries()) .map(([label, mpns]) => ({ prefix: label, label, mpns: mpns.sort((a, b) => a.mpn.localeCompare(b.mpn)), })) .sort((a, b) => a.label.localeCompare(b.label)); return { icMpns, icCount: icMpns.reduce((sum, e) => sum + e.refs.length, 0), passiveCount, passiveGroups, simpleGroups, totalRefs, }; } // --------------------------------------------------------------------------- // LCSC detection (mirror of backend.services.purple_parts.detect_lcsc_column). // Run on the client so we know during the columns step whether the BOM upload // will trigger an LCSC → MPN rewrite. A single non-LCSC entry disqualifies // the column, matching the backend's column-level all-or-nothing rule. // --------------------------------------------------------------------------- const LCSC_ID_RE = /^C\d+$/; function detectLcscColumn( rows: Record[], mpnCol: string, ): boolean { let seenAny = false; for (const r of rows) { const v = (r[mpnCol] || "").trim(); if (!v) continue; if (!LCSC_ID_RE.test(v)) return false; seenAny = true; } return seenAny; } /** * Build a one-line summary like "10kΩ · 0603 · 50V" from the auto-resolve * response's `model` field (a serialized ResistorSpecs / CapacitorSpecs / * InductorSpecs / SimpleComponentSpecs). Shown next to the resolved row. */ function summarizeLcscModel(model: Record): string { const parts: string[] = []; const value = model.value_formatted; if (typeof value === "string" && value) parts.push(value); const pkg = model.package; if (typeof pkg === "string" && pkg) parts.push(pkg); const v = model.voltage_rating_v ?? model.voltage_rating; if (typeof v === "string" && v) parts.push(v); return parts.join(" · "); } // --------------------------------------------------------------------------- // Wizard step definitions // --------------------------------------------------------------------------- type WizardStep = | "details" | "columns" | "subdesigns" | "lcsc-passives" | "datasheets" | "simple" | "passives"; const ALL_STEPS: { key: WizardStep; label: string }[] = [ { key: "details", label: "Project Details" }, { key: "columns", label: "BOM Columns" }, { key: "subdesigns", label: "Sub-designs" }, { key: "lcsc-passives", label: "Resolving Passive Specs" }, { key: "datasheets", label: "IC Datasheets" }, { key: "simple", label: "Component Datasheets (Optional)" }, { key: "passives", label: "Passive Datasheets (Optional)" }, ]; // Sentinel for sub-design id `null` (bare-named cells with no &NNNN prefix). // Sets only carry strings, so we tag this case with an empty-string key. const NULL_SUBDESIGN_KEY = ""; const _subKey = (id: string | null): string => id ?? NULL_SUBDESIGN_KEY; // Concurrency cap for the per-row LCSC passive resolve. Backend charges one // credit-billing API call per request; small batch keeps latency reasonable // without overloading the resolve endpoint. const LCSC_RESOLVE_CONCURRENCY = 5; // Cap on parallel datasheet fetches per step. const AUTO_FETCH_CONCURRENCY = 8; function naturalSortKey(s: string): (string | number)[] { const parts: (string | number)[] = []; const re = /(\d+)|(\D+)/g; let m: RegExpExecArray | null; while ((m = re.exec(s)) !== null) { parts.push(m[1] !== undefined ? parseInt(m[1], 10) : m[2].toLowerCase()); } return parts; } function naturalCompare(a: string, b: string): number { const ka = naturalSortKey(a); const kb = naturalSortKey(b); const n = Math.min(ka.length, kb.length); for (let i = 0; i < n; i++) { const x = ka[i]; const y = kb[i]; if (typeof x === typeof y) { if (x < y) return -1; if (x > y) return 1; } else { return typeof x === "number" ? -1 : 1; } } return ka.length - kb.length; } /** * Parse a PADS-PCB netlist directly in the browser to produce the * designator→pins mapping. Avoids a backend round-trip + cold-start latency * on the Create Project flow. * * Tolerant of the common format: splits on `*SIGNAL*` delimiters, then for * each block reads the net name (first line) and tokenises the remaining * lines as `REF.PIN` pairs until the next `*`-prefixed section marker. */ function parseNetlistPreview(text: string): NetlistPreviewDesignator[] { const byRef = new Map>(); const blocks = text.split(/\*SIGNAL\*/); for (let i = 1; i < blocks.length; i++) { const lines = blocks[i].split(/\r?\n/); const netName = lines[0].trim(); if (!netName) continue; for (let j = 1; j < lines.length; j++) { const line = lines[j].trim(); if (!line) continue; if (line.startsWith("*")) break; for (const tok of line.split(/\s+/)) { const dot = tok.indexOf("."); if (dot <= 0 || dot === tok.length - 1) continue; const ref = tok.slice(0, dot); const pin = tok.slice(dot + 1); let pins = byRef.get(ref); if (!pins) { pins = new Map(); byRef.set(ref, pins); } if (!pins.has(pin)) pins.set(pin, netName); } } } const refs = [...byRef.keys()].sort(naturalCompare); return refs.map((ref) => { const pinsMap = byRef.get(ref)!; const pins = [...pinsMap.entries()] .sort((a, b) => naturalCompare(a[0], b[0])) .map(([number, net_name]) => ({ number, net_name })); return { ref, pins }; }); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- interface CreateProjectDialogProps { disabled?: boolean; onCreateProject: (project: Project) => void; rerunProject?: Project | null; onRerunDone?: () => void; cloneAsNewProject?: Project | null; onCloneAsNewDone?: () => void; } function countNetsInNetlist(text: string): number { return (text.match(/\*SIGNAL\*/g) || []).length; } // EDIF files are s-expression Lisp-like — backend handles them, but the // PADS-shape preview functions don't, so detect early and skip them. function isEdifNetlist(text: string): boolean { return text.slice(0, 1024).trimStart().slice(0, 5).toLowerCase() === "(edif"; } function SuggestedDatasheetLinks({ urls }: { urls: string[] }) { if (!urls.length) return null; return (
{urls.slice(0, 4).map((href) => { let host = href; try { host = new URL(href).hostname.replace(/^www\./, ""); } catch { /* keep raw */ } return ( e.stopPropagation()} > Open in browser · {host} ); })}
); } export function CreateProjectDialog({ disabled, onCreateProject, rerunProject, onRerunDone, cloneAsNewProject, onCloneAsNewDone, }: CreateProjectDialogProps) { const [open, setOpen] = useState(false); const [step, setStep] = useState("details"); // Rerun mode: operate on an existing project rather than creating new. const [existingProjectId, setExistingProjectId] = useState(null); const [initialBomFile, setInitialBomFile] = useState(null); const [initialNetlistFile, setInitialNetlistFile] = useState(null); const [existingDatasheetStems, setExistingDatasheetStems] = useState>( new Set(), ); const [prefillLoading, setPrefillLoading] = useState(false); const [prefillError, setPrefillError] = useState(null); // Pre-flight credit estimate (shown under Run button on the final step). const [estimate, setEstimate] = useState(null); const [balance, setBalance] = useState(null); const [estimateLoading, setEstimateLoading] = useState(false); // Step 1 const [name, setName] = useState(""); const [bomFile, setBomFile] = useState(null); const [netlistFile, setNetlistFile] = useState(null); const [netlistNetCount, setNetlistNetCount] = useState(null); const [netlistError, setNetlistError] = useState(null); // Step 2 const [csvData, setCsvData] = useState | null>( null, ); const [refCol, setRefCol] = useState(null); const [mpnCol, setMpnCol] = useState(null); // Step 3 const [datasheetFiles, setDatasheetFiles] = useState( new Map(), ); // Step 3.5 — simple component datasheets (per-MPN, optional) const [simpleDatasheetFiles, setSimpleDatasheetFiles] = useState( new Map(), ); // Step 4 const [passiveDatasheetFiles, setPassiveDatasheetFiles] = useState( new Map(), ); // Step 5 — power sources (optional) const [netlistPreview, setNetlistPreview] = useState< NetlistPreviewDesignator[] | null >(null); const [netlistPreviewError, setNetlistPreviewError] = useState( null, ); // Creation state const [creating, setCreating] = useState(false); const [progress, setProgress] = useState(""); const [error, setError] = useState(null); // Library resolution state const [resolvedIcMpns, setResolvedIcMpns] = useState>(new Set()); const [resolvedPassiveMpns, setResolvedPassiveMpns] = useState>( new Set(), ); const [resolvedSimpleMpns, setResolvedSimpleMpns] = useState>( new Set(), ); const [libraryDatasheets, setLibraryDatasheets] = useState>( new Set(), ); const [checkingLibrary, setCheckingLibrary] = useState(false); // Auto-resolve state: per-MPN status type ResolveStatus = "resolving" | "resolved" | "failed"; const [resolveStatus, setResolveStatus] = useState>(new Map()); const [resolveErrors, setResolveErrors] = useState>(new Map()); const [autoResolving, setAutoResolving] = useState(false); // Auto-fetch state: per-MPN status type FetchStatus = "fetching" | "failed"; const [fetchStatus, setFetchStatus] = useState>(new Map()); const [fetchErrors, setFetchErrors] = useState>(new Map()); const [fetchUrls, setFetchUrls] = useState>(new Map()); const [fetchUrlLists, setFetchUrlLists] = useState>(new Map()); const [fetchSources, setFetchSources] = useState>(new Map()); const [autoFetching, setAutoFetching] = useState(false); // ---- LCSC bridging state ---- // Populated either by an early BOM upload (new project path) or by rerun // prefill (existing project with cached LCSC data). `lcscToMpn` keys are // the original LCSC ids the user uploaded; values are real MPNs the BOM // was rewritten to. `mpnToLcsc` is the reverse — letting per-row UI find // the LCSC id given the resolved MPN. Treated as null when the BOM had // no LCSC ids at all (suppresses the LCSC passive-resolve step entirely). const [lcscToMpn, setLcscToMpn] = useState | null>(null); const [lcscPayloads, setLcscPayloads] = useState | null>(null); // True once the BOM has been pushed to the server in early-upload mode. // Used by handleCreate to skip the redundant upload at submit time. const [bomUploadedEarly, setBomUploadedEarly] = useState(false); // Same pattern for EDIF netlists: we need the sub-design list before the // user reaches the picker step, so we eagerly upload on Details → Next. const [netlistUploadedEarly, setNetlistUploadedEarly] = useState(false); const [earlyUploading, setEarlyUploading] = useState(false); const [earlyUploadError, setEarlyUploadError] = useState(null); // EDIF sub-design state, populated from the early netlist upload response. // `selectedSubdesignIds = null` means "include every sub-design" (default // for single-design EDIFs and pre-confirmation state). String ids include // "" as a sentinel for sub-design id `null` (bare-named cells). const [edifSubDesigns, setEdifSubDesigns] = useState([]); const [selectedSubdesignIds, setSelectedSubdesignIds] = useState< Set | null >(null); // True when the selected netlist's first bytes start with `(edif`. Detected // in handleNetlistChange so step transitions don't have to re-read the file. const [netlistIsEdif, setNetlistIsEdif] = useState(false); // Per-LCSC-id resolve status for the lcsc-passives step. type LcscStatus = "pending" | "resolving" | "resolved" | "failed"; const [lcscStatus, setLcscStatus] = useState>(new Map()); const [lcscErrors, setLcscErrors] = useState>(new Map()); const [lcscSummaries, setLcscSummaries] = useState>(new Map()); const [lcscResolving, setLcscResolving] = useState(false); const [lcscOutOfCredits, setLcscOutOfCredits] = useState(false); // ---- Derived ---- const classification = useMemo(() => { if (!csvData || !refCol || !mpnCol) return null; return classifyBom(csvData.rows, refCol, mpnCol); }, [csvData, refCol, mpnCol]); // True when the user's chosen MPN column is entirely LCSC ids. Recomputed // whenever the CSV / column choice changes. Cleared once the early upload // has run because at that point csvData rows have been rewritten to real // MPNs (lcscToMpn is the post-upload source of truth). const lcscDetectedClient = useMemo(() => { if (!csvData || !mpnCol) return false; if (bomUploadedEarly) return false; return detectLcscColumn(csvData.rows, mpnCol); }, [csvData, mpnCol, bomUploadedEarly]); // Reverse map MPN → LCSC for per-row LCSC-id display in the IC datasheets // step. Built from lcscToMpn (post-upload state); empty otherwise. const mpnToLcsc = useMemo(() => { const m = new Map(); if (lcscToMpn) { for (const [lcsc, mpn] of Object.entries(lcscToMpn)) { if (mpn) m.set(mpn, lcsc); } } return m; }, [lcscToMpn]); // Check library whenever classification changes useEffect(() => { if (!classification) { setResolvedIcMpns(new Set()); setResolvedPassiveMpns(new Set()); setResolvedSimpleMpns(new Set()); return; } const icMpns = classification.icMpns.map((e) => e.mpn); const passiveMpns = classification.passiveGroups.flatMap((g) => g.mpns.map((e) => e.mpn), ); const simpleMpns = classification.simpleGroups.flatMap((g) => g.mpns.map((e) => e.mpn), ); if (icMpns.length === 0 && passiveMpns.length === 0 && simpleMpns.length === 0) return; setCheckingLibrary(true); checkLibrary(icMpns, passiveMpns, simpleMpns).then((res) => { setResolvedIcMpns(new Set(res.ic_resolved)); setResolvedPassiveMpns(new Set(res.passive_resolved)); setResolvedSimpleMpns(new Set(res.simple_resolved)); setLibraryDatasheets(new Set(res.datasheets_available ?? [])); }).finally(() => { setCheckingLibrary(false); }); }, [classification]); // Filter to only unresolved MPNs const unresolvedIcMpns = useMemo( () => classification?.icMpns.filter((e) => !resolvedIcMpns.has(e.mpn)) ?? [], [classification, resolvedIcMpns], ); const unresolvedPassiveGroups = useMemo(() => { if (!classification) return []; return classification.passiveGroups .map((g) => ({ ...g, mpns: g.mpns.filter((e) => !resolvedPassiveMpns.has(e.mpn)), })) .filter((g) => g.mpns.length > 0); }, [classification, resolvedPassiveMpns]); const unresolvedSimpleGroups = useMemo(() => { if (!classification) return []; return classification.simpleGroups .map((g) => ({ ...g, mpns: g.mpns.filter((e) => !resolvedSimpleMpns.has(e.mpn)), })) .filter((g) => g.mpns.length > 0); }, [classification, resolvedSimpleMpns]); const hasIcs = unresolvedIcMpns.length > 0; const hasPassives = unresolvedPassiveGroups.length > 0; const hasSimple = unresolvedSimpleGroups.length > 0; // List of passives that originated as LCSC ids — these are the candidates // for the lcsc-passives step. We resolve via the LCSC payload (cached on // the project) rather than via the MPN, because the payload already // carries the description we need. // // Filtering rules: // - the passive MPN must appear in mpnToLcsc (came from an LCSC id) // - skip if already resolved via library // - skip if already resolved during this wizard session const lcscPassives = useMemo(() => { if (!classification || mpnToLcsc.size === 0) return []; const out: { mpn: string; lcsc: string; refs: string[]; description: string | null }[] = []; const seen = new Set(); for (const g of classification.passiveGroups) { for (const e of g.mpns) { const lcsc = mpnToLcsc.get(e.mpn); if (!lcsc || seen.has(lcsc)) continue; if (resolvedPassiveMpns.has(e.mpn)) continue; if (lcscStatus.get(lcsc) === "resolved") continue; seen.add(lcsc); const payload = lcscPayloads?.[lcsc]; out.push({ mpn: e.mpn, lcsc, refs: e.refs, description: payload?.description ?? null, }); } } return out; }, [classification, mpnToLcsc, lcscPayloads, resolvedPassiveMpns, lcscStatus]); const hasLcscPassives = lcscPassives.length > 0; const hasSubdesignChoice = edifSubDesigns.length >= 2; // Per-sub-design BOM coverage (how many of its designators appear in the // BOM the user picked). Cheap recompute on csvData / refCol / sub-designs. const subdesignCoverage = useMemo(() => { if (!csvData || !refCol) return new Map(); const bomRefs = new Set(); for (const row of csvData.rows) { const cell = (row[refCol] || "").trim(); for (const r of cell.split(",").map((s) => s.trim()).filter(Boolean)) { bomRefs.add(r); } } const out = new Map(); for (const sd of edifSubDesigns) { let n = 0; for (const d of sd.designators) if (bomRefs.has(d)) n++; out.set(_subKey(sd.id), n); } return out; }, [csvData, refCol, edifSubDesigns]); // Smart auto-pick decision. Returns the case kind plus the set of // sub-design keys to pre-select. "clean" = exactly one sub-design has // BOM coverage (auto-confirm banner UX); "ambiguous" = multiple covered // or none covered (explicit picker UX). const subdesignSuggestion = useMemo(() => { if (!hasSubdesignChoice) return null; const covered: string[] = []; for (const sd of edifSubDesigns) { if ((subdesignCoverage.get(_subKey(sd.id)) ?? 0) > 0) { covered.push(_subKey(sd.id)); } } if (covered.length === 1) { return { kind: "clean" as const, preselect: new Set(covered) }; } // Pre-select either all covered (split case) or all sub-designs (none // covered) so the user has a sane starting point in the picker. return { kind: "ambiguous" as const, preselect: covered.length > 1 ? new Set(covered) : new Set(edifSubDesigns.map((sd) => _subKey(sd.id))), }; }, [hasSubdesignChoice, edifSubDesigns, subdesignCoverage]); // Seed selectedSubdesignIds once the suggestion is known and the user // hasn't already touched it. Re-runs when the suggestion changes (e.g., // column choice flips coverage), but only when selection is still null. useEffect(() => { if (selectedSubdesignIds === null && subdesignSuggestion) { setSelectedSubdesignIds(subdesignSuggestion.preselect); } }, [subdesignSuggestion, selectedSubdesignIds]); const activeSteps = useMemo(() => { return ALL_STEPS.filter((s) => { // Always include the step the user is currently on so the // "Step N of M" counter stays valid mid-transition (e.g. when // lcsc-passives drains to zero before the auto-advance effect // moves us off the step on the next tick). if (s.key === step) return true; if (s.key === "subdesigns") return hasSubdesignChoice; if (s.key === "lcsc-passives") return hasLcscPassives; if (s.key === "datasheets") return hasIcs; if (s.key === "simple") return hasSimple; if (s.key === "passives") return hasPassives; return true; }); }, [step, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]); const stepIndex = activeSteps.findIndex((s) => s.key === step); // ---- Handlers ---- const handleBomChange = useCallback((files: File[]) => { const file = files[0] || null; setBomFile(file); if (!file) { setCsvData(null); setRefCol(null); setMpnCol(null); return; } const isXlsx = file.name.toLowerCase().endsWith(".xlsx"); if (isXlsx) { file.arrayBuffer().then((buf) => { const parsed = parseXlsx(buf); setCsvData(parsed); setRefCol(autoDetect(parsed.headers, REF_CANDIDATES)); setMpnCol(autoDetect(parsed.headers, MPN_CANDIDATES)); }); } else { file.text().then((text) => { const parsed = parseCsv(text); setCsvData(parsed); setRefCol(autoDetect(parsed.headers, REF_CANDIDATES)); setMpnCol(autoDetect(parsed.headers, MPN_CANDIDATES)); }); } }, []); const handleNetlistChange = useCallback((files: File[]) => { const file = files[0] || null; setNetlistFile(file); setNetlistNetCount(null); setNetlistError(null); // Invalidate any cached netlist preview, since it was relative to the // previous file. setNetlistPreview(null); setNetlistPreviewError(null); // Re-uploads invalidate any prior EDIF sub-design data + selection. setEdifSubDesigns([]); setSelectedSubdesignIds(null); setNetlistUploadedEarly(false); setNetlistIsEdif(false); if (!file) return; file.text().then((text) => { // EDIF: skip browser-side preview — the net-count badge will populate // after the server upload parses the file. if (isEdifNetlist(text)) { setNetlistNetCount(null); setNetlistPreview([]); setNetlistIsEdif(true); return; } setNetlistIsEdif(false); setNetlistNetCount(countNetsInNetlist(text)); try { setNetlistPreview(parseNetlistPreview(text)); } catch (e) { setNetlistPreviewError( e instanceof Error ? e.message : "Failed to parse netlist", ); } }); }, []); const resetAndClose = useCallback(() => { setOpen(false); setStep("details"); setName(""); setBomFile(null); setNetlistFile(null); setNetlistNetCount(null); setNetlistError(null); setCsvData(null); setRefCol(null); setMpnCol(null); setDatasheetFiles(new Map()); setSimpleDatasheetFiles(new Map()); setPassiveDatasheetFiles(new Map()); setResolvedIcMpns(new Set()); setResolvedPassiveMpns(new Set()); setResolvedSimpleMpns(new Set()); setFetchStatus(new Map()); setFetchErrors(new Map()); setFetchUrls(new Map()); setFetchUrlLists(new Map()); setAutoFetching(false); setResolveStatus(new Map()); setResolveErrors(new Map()); setAutoResolving(false); setNetlistPreview(null); setNetlistPreviewError(null); setCreating(false); setProgress(""); setError(null); setExistingProjectId(null); setInitialBomFile(null); setInitialNetlistFile(null); setExistingDatasheetStems(new Set()); setPrefillLoading(false); setPrefillError(null); setEstimate(null); setBalance(null); setEstimateLoading(false); setLcscToMpn(null); setLcscPayloads(null); setBomUploadedEarly(false); setNetlistUploadedEarly(false); setEdifSubDesigns([]); setSelectedSubdesignIds(null); setNetlistIsEdif(false); setEarlyUploading(false); setEarlyUploadError(null); setLcscStatus(new Map()); setLcscErrors(new Map()); setLcscSummaries(new Map()); setLcscResolving(false); setLcscOutOfCredits(false); autoFetchedSteps.current = new Set(); onRerunDone?.(); onCloneAsNewDone?.(); }, [onRerunDone, onCloneAsNewDone]); // Prefill state from a cancelled/complete project when entering rerun mode. useEffect(() => { if (!rerunProject) return; let cancelled = false; setOpen(true); setPrefillLoading(true); setPrefillError(null); setExistingProjectId(rerunProject.id); setName(rerunProject.name); setStep("details"); (async () => { try { // Drafts are already editable; only non-drafts need a status reset // to clear pipeline artifacts before the rerun. if (rerunProject.status !== "draft") { await reopenProject(rerunProject.id); } // Pull LCSC bridging data off the existing project meta. The BOM // already-stored on the server has resolved MPNs; we just need the // map for per-row LCSC-id display and the payloads for the // lcsc-passives step. if (rerunProject.lcscToMpn && Object.keys(rerunProject.lcscToMpn).length > 0) { setLcscToMpn(rerunProject.lcscToMpn); } if (rerunProject.lcscPayloads && Object.keys(rerunProject.lcscPayloads).length > 0) { setLcscPayloads(rerunProject.lcscPayloads); } const [bom, netlist, dsStems] = await Promise.all([ rerunProject.hasBom ? downloadProjectBom(rerunProject.id).catch(() => null) : Promise.resolve(null), rerunProject.hasNetlist ? downloadProjectNetlist(rerunProject.id).catch(() => null) : Promise.resolve(null), fetchProjectDatasheets(rerunProject.id).catch(() => new Set()), ]); if (cancelled) return; // BOM: parse + restore column mappings. if (bom) { const isXlsx = bom.name.toLowerCase().endsWith(".xlsx"); const parsed = isXlsx ? parseXlsx(await bom.arrayBuffer()) : parseCsv(await bom.text()); if (cancelled) return; setBomFile(bom); setInitialBomFile(bom); setCsvData(parsed); const saved = rerunProject.bomColumns; setRefCol( (saved?.reference && parsed.headers.includes(saved.reference) ? saved.reference : autoDetect(parsed.headers, REF_CANDIDATES)) ?? null, ); setMpnCol( (saved?.mpn && parsed.headers.includes(saved.mpn) ? saved.mpn : autoDetect(parsed.headers, MPN_CANDIDATES)) ?? null, ); } // Netlist: parse preview + count nets. if (netlist) { const netlistText = await netlist.text(); if (cancelled) return; setNetlistFile(netlist); setInitialNetlistFile(netlist); if (isEdifNetlist(netlistText)) { setNetlistIsEdif(true); // EDIF: PADS-shape browser preview doesn't apply. setNetlistNetCount(null); setNetlistPreview([]); } else { setNetlistIsEdif(false); setNetlistNetCount(countNetsInNetlist(netlistText)); try { setNetlistPreview(parseNetlistPreview(netlistText)); } catch (e) { setNetlistPreviewError( e instanceof Error ? e.message : "Failed to parse netlist", ); } } } // Existing datasheets (server-stored; kept as stems). setExistingDatasheetStems(dsStems); } catch (e) { if (!cancelled) { setPrefillError( e instanceof Error ? e.message : "Failed to load project data", ); } } finally { if (!cancelled) setPrefillLoading(false); } })(); return () => { cancelled = true; }; }, [rerunProject]); // Prefill state for "rerun as new project" — admin clone flow. // Mirrors the rerun effect but does NOT set existingProjectId, so submit // creates a brand new project. Skips datasheet stems (clone re-extracts them). useEffect(() => { if (!cloneAsNewProject) return; let cancelled = false; setOpen(true); setPrefillLoading(true); setPrefillError(null); setName(`[Auto-update] ${cloneAsNewProject.name}`); setStep("details"); (async () => { try { const [bom, netlist] = await Promise.all([ cloneAsNewProject.hasBom ? downloadProjectBom(cloneAsNewProject.id).catch(() => null) : Promise.resolve(null), cloneAsNewProject.hasNetlist ? downloadProjectNetlist(cloneAsNewProject.id).catch(() => null) : Promise.resolve(null), ]); if (cancelled) return; if (bom) { const isXlsx = bom.name.toLowerCase().endsWith(".xlsx"); const parsed = isXlsx ? parseXlsx(await bom.arrayBuffer()) : parseCsv(await bom.text()); if (cancelled) return; setBomFile(bom); setCsvData(parsed); const saved = cloneAsNewProject.bomColumns; setRefCol( (saved?.reference && parsed.headers.includes(saved.reference) ? saved.reference : autoDetect(parsed.headers, REF_CANDIDATES)) ?? null, ); setMpnCol( (saved?.mpn && parsed.headers.includes(saved.mpn) ? saved.mpn : autoDetect(parsed.headers, MPN_CANDIDATES)) ?? null, ); } if (netlist) { const netlistText = await netlist.text(); if (cancelled) return; setNetlistFile(netlist); if (isEdifNetlist(netlistText)) { setNetlistIsEdif(true); setNetlistNetCount(null); setNetlistPreview([]); } else { setNetlistIsEdif(false); setNetlistNetCount(countNetsInNetlist(netlistText)); try { setNetlistPreview(parseNetlistPreview(netlistText)); } catch (e) { setNetlistPreviewError( e instanceof Error ? e.message : "Failed to parse netlist", ); } } } } catch (e) { if (!cancelled) { setPrefillError( e instanceof Error ? e.message : "Failed to load project data", ); } } finally { if (!cancelled) setPrefillLoading(false); } })(); return () => { cancelled = true; }; }, [cloneAsNewProject]); // Fetch a pre-flight credit estimate when entering the final step of // an edit/rerun. Requires a BOM already on the server — drafts where // the user just uploaded a new BOM via the modal haven't been pushed // to the backend yet, so skip until there's something to estimate. useEffect(() => { if (!authEnabled) return; // OSS mode: no credits — no estimate UI if (!existingProjectId) return; if (step !== activeSteps[activeSteps.length - 1]?.key) return; if (!initialBomFile) return; let cancelled = false; setEstimateLoading(true); Promise.all([ fetchPipelineEstimate(existingProjectId), fetchCredits(), ]) .then(([est, cr]) => { if (cancelled) return; setEstimate(est); setBalance(cr.balance); }) .catch(() => { /* Estimate is best-effort; swallow so the Run button still works. */ }) .finally(() => { if (!cancelled) setEstimateLoading(false); }); return () => { cancelled = true; }; }, [existingProjectId, step]); const handleAutoFetch = useCallback( async ( mpns: string[], setter: React.Dispatch>>, existing: Map, ) => { // Filter to MPNs that don't already have a file, library entry, // or server-side upload from an earlier session. const toFetch = mpns.filter( (m) => !existing.has(m) && !libraryDatasheets.has(m) && !existingDatasheetStems.has(safeMpn(m)), ); if (toFetch.length === 0) return; setAutoFetching(true); // Spinner on every row immediately, before any await. setFetchStatus((prev) => { const next = new Map(prev); for (const mpn of toFetch) next.set(mpn, "fetching"); return next; }); const fetchOne = async (mpn: string) => { try { const { file, url, source } = await fetchAutoDatasheet( mpn, mpnToLcsc.get(mpn), ); setter((prev) => new Map(prev).set(mpn, file)); if (url) setFetchUrls((prev) => new Map(prev).set(mpn, url)); if (source) setFetchSources((prev) => new Map(prev).set(mpn, source)); setFetchStatus((prev) => { const next = new Map(prev); next.delete(mpn); return next; }); setFetchErrors((prev) => { const next = new Map(prev); next.delete(mpn); return next; }); } catch (e) { const msg = e instanceof Error ? e.message : "Fetch failed"; setFetchStatus((prev) => new Map(prev).set(mpn, "failed")); setFetchErrors((prev) => new Map(prev).set(mpn, msg)); if (e instanceof DatasheetFetchError && e.url) { setFetchUrls((prev) => new Map(prev).set(mpn, e.url!)); } if (e instanceof DatasheetFetchError && e.urls.length) { setFetchUrlLists((prev) => new Map(prev).set(mpn, e.urls)); } if (e instanceof DatasheetFetchError && e.source) { setFetchSources((prev) => new Map(prev).set(mpn, e.source!)); } } }; // Worker pool: up to AUTO_FETCH_CONCURRENCY in flight, cursor pulls next. let cursor = 0; const workers = Array.from( { length: Math.min(AUTO_FETCH_CONCURRENCY, toFetch.length) }, async () => { while (cursor < toFetch.length) { const i = cursor++; await fetchOne(toFetch[i]); } }, ); await Promise.all(workers); setAutoFetching(false); }, [libraryDatasheets, existingDatasheetStems, mpnToLcsc], ); // Track which datasheet steps already auto-fetched in this dialog session // so navigating back-and-forth doesn't refire. Reset in resetAndClose. const autoFetchedSteps = useRef>(new Set()); useEffect(() => { if (checkingLibrary || autoFetching) return; if ( step === "datasheets" && !autoFetchedSteps.current.has("datasheets") && unresolvedIcMpns.length > 0 ) { autoFetchedSteps.current.add("datasheets"); handleAutoFetch( unresolvedIcMpns.map((e) => e.mpn), setDatasheetFiles, datasheetFiles, ); } else if ( step === "simple" && !autoFetchedSteps.current.has("simple") && unresolvedSimpleGroups.length > 0 ) { autoFetchedSteps.current.add("simple"); // Skip connectors — their datasheets are truly optional and rarely // contribute anything the reviewer can act on, so we don't burn the // DigiKey budget on them. const toFetch = unresolvedSimpleGroups .flatMap((g) => g.mpns) .filter((e) => e.refPrefix !== "J") .map((e) => e.mpn); if (toFetch.length > 0) { handleAutoFetch(toFetch, setSimpleDatasheetFiles, simpleDatasheetFiles); } } }, [ step, checkingLibrary, autoFetching, unresolvedIcMpns, unresolvedSimpleGroups, handleAutoFetch, datasheetFiles, simpleDatasheetFiles, ]); const handleAutoResolve = useCallback( async (groups: SimpleGroup[]) => { // Collect MPNs not already in library and not already resolved const toResolve = groups.flatMap((g) => g.mpns.filter( (e) => !resolvedSimpleMpns.has(e.mpn) && resolveStatus.get(e.mpn) !== "resolved", ), ); if (toResolve.length === 0) return; setAutoResolving(true); // Mark all as resolving setResolveStatus((prev) => { const next = new Map(prev); for (const { mpn } of toResolve) next.set(mpn, "resolving"); return next; }); // Build items with component type from ref prefix const items = toResolve.map((e) => ({ mpn: e.mpn, component_type: SIMPLE_PREFIX_TO_TYPE[e.refPrefix] || "discrete", })); try { const { results } = await autoResolveSimple(items); const newResolved = new Set(); setResolveStatus((prev) => { const next = new Map(prev); for (const r of results) { next.set(r.mpn, r.status); if (r.status === "resolved") newResolved.add(r.mpn); } return next; }); setResolveErrors((prev) => { const next = new Map(prev); for (const r of results) { if (r.error) next.set(r.mpn, r.error); else next.delete(r.mpn); } return next; }); // Add resolved MPNs to library set so they persist across steps if (newResolved.size > 0) { setResolvedSimpleMpns((prev) => new Set([...prev, ...newResolved])); } } catch { // Batch-level failure: mark all as failed setResolveStatus((prev) => { const next = new Map(prev); for (const { mpn } of toResolve) next.set(mpn, "failed"); return next; }); } setAutoResolving(false); }, [resolvedSimpleMpns, resolveStatus], ); const handleAutoResolvePassives = useCallback( async (groups: PassiveGroup[]) => { const toResolve = groups.flatMap((g) => g.mpns.filter( (e) => !resolvedPassiveMpns.has(e.mpn) && resolveStatus.get(e.mpn) !== "resolved", ), ); if (toResolve.length === 0) return; setAutoResolving(true); setResolveStatus((prev) => { const next = new Map(prev); for (const { mpn } of toResolve) next.set(mpn, "resolving"); return next; }); const items = toResolve.map((e) => ({ mpn: e.mpn, component_type: "passive", })); try { const { results } = await autoResolveSimple(items); const newResolved = new Set(); setResolveStatus((prev) => { const next = new Map(prev); for (const r of results) { next.set(r.mpn, r.status); if (r.status === "resolved") newResolved.add(r.mpn); } return next; }); setResolveErrors((prev) => { const next = new Map(prev); for (const r of results) { if (r.error) next.set(r.mpn, r.error); else next.delete(r.mpn); } return next; }); if (newResolved.size > 0) { setResolvedPassiveMpns((prev) => new Set([...prev, ...newResolved])); } } catch { setResolveStatus((prev) => { const next = new Map(prev); for (const { mpn } of toResolve) next.set(mpn, "failed"); return next; }); } setAutoResolving(false); }, [resolvedPassiveMpns, resolveStatus], ); // ---- Early BOM upload (LCSC-triggered) ---- // // When the user has chosen the columns and the MPN column is detected as // entirely LCSC ids, we cannot just continue with client-side state — // every downstream step (IC datasheet fetch, passive specs) needs real // MPNs. We do an early createProject + uploadBom so the backend can run // its purple-parts rewrite. After this we: // - update local csvData rows so the chosen MPN column holds resolved MPNs // - stash the LCSC → MPN map and full payloads for downstream rendering // - mark bomUploadedEarly so handleCreate doesn't re-upload at submit // // Returns true on success along with the resolved map and the rewritten // csv rows, so the caller can route to the right next step without // waiting for React state to re-derive (state updates queued here only // take effect on the next render). // // Failures leave bomUploadedEarly = false so handleCreate retries the // upload in its normal slot. The orphan draft project is cleaned up so // failed early-upload attempts don't pile up. const runEarlyUpload = useCallback(async (): Promise<{ ok: boolean; lcscMap: Record; rewrittenRows: Record[]; }> => { if (!csvData || !bomFile || !refCol || !mpnCol) { return { ok: false, lcscMap: {}, rewrittenRows: [] }; } setEarlyUploading(true); setEarlyUploadError(null); let createdProjectId: string | null = null; try { const proj = await createProject(name.trim() || "Untitled"); createdProjectId = proj.id; const uploadRes = await uploadBom(proj.id, bomFile, refCol, mpnCol); // Pull the full project meta back so we get lcsc_payloads (the upload // response only carries lcsc_to_mpn). const fullProj = await fetchProject(proj.id); setExistingProjectId(proj.id); setBomUploadedEarly(true); setInitialBomFile(bomFile); // Treat the netlist as fresh so it still uploads in handleCreate. setInitialNetlistFile(null); const map = uploadRes.lcsc_to_mpn ?? {}; if (Object.keys(map).length > 0) setLcscToMpn(map); if (fullProj.lcscPayloads && Object.keys(fullProj.lcscPayloads).length > 0) { setLcscPayloads(fullProj.lcscPayloads); } // Rewrite local csvData mpn column so client-side classification // sees real MPNs from here on. This keeps the existing IC / passive / // simple step plumbing unchanged. const rewrittenRows = csvData.rows.map((r) => { const lcsc = (r[mpnCol] || "").trim(); const mpn = map[lcsc]; if (mpn) return { ...r, [mpnCol]: mpn }; return r; }); setCsvData({ headers: csvData.headers, rows: rewrittenRows }); return { ok: true, lcscMap: map, rewrittenRows }; } catch (e) { // Clean up the orphan draft on failure to avoid littering the user's // dashboard with empty retry projects. if (createdProjectId) { try { await deleteProject(createdProjectId); } catch { // ignore — surface the original error } } setEarlyUploadError( e instanceof Error ? e.message : "Failed to resolve LCSC parts", ); return { ok: false, lcscMap: {}, rewrittenRows: [] }; } finally { setEarlyUploading(false); } }, [csvData, bomFile, refCol, mpnCol, name]); // EDIF netlists carry sub-design info we need before the picker step // renders. Triggered on Details → Next when the netlist is EDIF and the // file hasn't already been uploaded (rerun mode skips this — the netlist // is already on the server). Reuses `existingProjectId` when set (e.g., // LCSC early upload ran first), otherwise creates the project. const runEarlyNetlistUpload = useCallback(async (): Promise<{ ok: boolean; subDesigns: EdifSubDesign[]; }> => { if (!netlistFile) return { ok: false, subDesigns: [] }; setEarlyUploading(true); setEarlyUploadError(null); let createdProjectIdHere: string | null = null; try { let projectId = existingProjectId; if (!projectId) { const proj = await createProject(name.trim() || "Untitled"); projectId = proj.id; createdProjectIdHere = proj.id; setExistingProjectId(proj.id); } const result = await uploadNetlist(projectId, netlistFile); setEdifSubDesigns(result.sub_designs); if (result.designator_pins.length > 0) { setNetlistPreview(result.designator_pins); } setNetlistUploadedEarly(true); setInitialNetlistFile(netlistFile); return { ok: true, subDesigns: result.sub_designs }; } catch (e) { // Only delete the project if we created it just now — leave LCSC's // earlier draft alone. if (createdProjectIdHere) { try { await deleteProject(createdProjectIdHere); setExistingProjectId(null); } catch { // swallow — surface original error } } setEarlyUploadError( e instanceof Error ? e.message : "Failed to upload netlist", ); return { ok: false, subDesigns: [] }; } finally { setEarlyUploading(false); } }, [netlistFile, existingProjectId, name]); // ---- LCSC per-row passive resolve ---- // // Walks the lcscPassives list in batches of LCSC_RESOLVE_CONCURRENCY, // calling POST /api/projects/{id}/lcsc/resolve-passive for each. On // 402 we stop the queue immediately so the user can top up; partial // progress (already-resolved rows) is preserved. The lcsc-passives step // is skippable so a failure of a single MPN doesn't block the wizard. const handleResolveLcscPassives = useCallback(async () => { if (!existingProjectId) return; const toResolve = lcscPassives.filter( (p) => lcscStatus.get(p.lcsc) !== "resolved" && lcscStatus.get(p.lcsc) !== "resolving", ); if (toResolve.length === 0) return; setLcscResolving(true); setLcscOutOfCredits(false); // Mark all as pending so the spinner appears immediately. setLcscStatus((prev) => { const next = new Map(prev); for (const p of toResolve) next.set(p.lcsc, "resolving"); return next; }); let cursor = 0; let stopped = false; const resolveOne = async (entry: typeof toResolve[number]) => { if (stopped) return; try { const res = await resolveLcscPassive(existingProjectId, entry.lcsc); const summary = summarizeLcscModel(res.model); setLcscStatus((prev) => new Map(prev).set(entry.lcsc, "resolved")); setLcscSummaries((prev) => new Map(prev).set(entry.lcsc, summary)); setLcscErrors((prev) => { const next = new Map(prev); next.delete(entry.lcsc); return next; }); // Mirror the existing pattern from handleAutoResolve: surface the // MPN as fully resolved so the standard passives step skips it. setResolvedPassiveMpns((prev) => new Set(prev).add(res.mpn)); } catch (e) { if (e instanceof LcscResolveError && e.status === 402) { stopped = true; setLcscOutOfCredits(true); // Reset still-running rows back to pending so the user can retry // after topping up. setLcscStatus((prev) => { const next = new Map(prev); for (const [k, v] of next.entries()) { if (v === "resolving") next.set(k, "pending"); } return next; }); return; } setLcscStatus((prev) => new Map(prev).set(entry.lcsc, "failed")); setLcscErrors((prev) => new Map(prev).set( entry.lcsc, e instanceof Error ? e.message : "Resolve failed", ), ); } }; const workers = Array.from( { length: Math.min(LCSC_RESOLVE_CONCURRENCY, toResolve.length) }, async () => { while (cursor < toResolve.length && !stopped) { const i = cursor++; await resolveOne(toResolve[i]); } }, ); await Promise.all(workers); setLcscResolving(false); }, [existingProjectId, lcscPassives, lcscStatus]); // Auto-kick the LCSC resolve when the user lands on the step. Same // once-per-session pattern as the IC datasheet auto-fetch above. useEffect(() => { if (step !== "lcsc-passives") return; if (autoFetchedSteps.current.has("lcsc-passives")) return; if (lcscPassives.length === 0) return; if (!existingProjectId) return; autoFetchedSteps.current.add("lcsc-passives"); handleResolveLcscPassives(); // We intentionally only auto-trigger once per dialog session — back/next // navigation should not re-fire (and re-charge) resolved rows. // eslint-disable-next-line react-hooks/exhaustive-deps }, [step, lcscPassives.length, existingProjectId]); // Auto-advance off the lcsc-passives step when there's nothing left to // resolve. Covers two cases: // 1) auto-fire completed and every row landed in lcscStatus="resolved", // so lcscPassives filters down to empty; // 2) the user navigated here but the project had no LCSC-resolved // passives in the first place (rare, but the step counter goes // wrong without this guard). useEffect(() => { if (step !== "lcsc-passives") return; if (lcscResolving) return; if (lcscPassives.length > 0) return; // Pick the next active step in display order. if (hasIcs) setStep("datasheets"); else if (hasSimple) setStep("simple"); else if (hasPassives) setStep("passives"); }, [step, lcscResolving, lcscPassives.length, hasIcs, hasSimple, hasPassives]); const canAdvance = (): boolean => { if (step === "details") return !!(name.trim() && bomFile && netlistFile && !netlistError); if (step === "columns") return !!(refCol && mpnCol); if (step === "subdesigns") return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0); return true; }; // Shared "what's after columns" routing — used by goNext on the columns // step and by goBack from the lcsc-passives step. When an EDIF file has // >=2 sub-designs we slot the "subdesigns" step in first. const stepAfterColumns = (): WizardStep | null => { if (hasSubdesignChoice) return "subdesigns"; if (hasLcscPassives) return "lcsc-passives"; if (hasIcs) return "datasheets"; if (hasSimple) return "simple"; if (hasPassives) return "passives"; return null; }; const stepAfterSubdesigns = (): WizardStep | null => { if (hasLcscPassives) return "lcsc-passives"; if (hasIcs) return "datasheets"; if (hasSimple) return "simple"; if (hasPassives) return "passives"; return null; }; const goNext = async () => { if (step === "details") { // EDIF: eagerly upload so we can show the sub-design picker before // the user reaches Run. Skip in rerun mode (netlist is already on // the server) and when already done. if ( !rerunProject && netlistIsEdif && !netlistUploadedEarly && netlistFile ) { const res = await runEarlyNetlistUpload(); if (!res.ok) return; } setStep("columns"); } else if (step === "columns") { // LCSC-detected BOMs need an early upload so we have the LCSC → MPN // map before the IC and passive steps. If the early upload fails we // stay on this step and surface the error rather than continuing // with stale LCSC ids. Rerun projects already have the BOM rewritten // server-side (so csvData rows have real MPNs and lcscDetectedClient // is false), but we guard explicitly for clarity. if (!rerunProject && !bomUploadedEarly && lcscDetectedClient) { const res = await runEarlyUpload(); if (!res.ok) return; // Determine the next step from the freshly returned data because // React state updates queued by runEarlyUpload (lcscToMpn, csvData) // haven't propagated to derived memos yet. We need to re-derive // hasLcscPassives off `res.lcscMap` and `res.rewrittenRows`. if (!refCol || !mpnCol) return; const cls = classifyBom(res.rewrittenRows, refCol, mpnCol); const lcscMpns = new Set(Object.values(res.lcscMap)); const passiveHit = cls.passiveGroups.some((g) => g.mpns.some((e) => lcscMpns.has(e.mpn) && !resolvedPassiveMpns.has(e.mpn)), ); if (passiveHit) setStep("lcsc-passives"); else if (cls.icMpns.length > 0) setStep("datasheets"); else if (cls.simpleGroups.length > 0) setStep("simple"); else if (cls.passiveGroups.length > 0) setStep("passives"); return; } const next = stepAfterColumns(); if (next) setStep(next); } else if (step === "subdesigns") { // Persist the selection before advancing. ``null`` means "include // every sub-design"; pass it explicitly so re-confirmations clear // any prior partial selection. if (existingProjectId) { const sel = selectedSubdesignIds; const list = sel === null ? null : Array.from(sel).map((k) => (k === NULL_SUBDESIGN_KEY ? "" : k)); try { await updateNetlistSubdesigns(existingProjectId, list); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save sub-design selection"); return; } } const next = stepAfterSubdesigns(); if (next) setStep(next); } else if (step === "lcsc-passives") { if (hasIcs) setStep("datasheets"); else if (hasSimple) setStep("simple"); else if (hasPassives) setStep("passives"); } else if (step === "datasheets") { // Warn if ICs are missing datasheets. In rerun/edit mode a datasheet // may already be stored in this project's uploads folder — match it // via existingDatasheetStems so we don't falsely flag them. const missingIcs = unresolvedIcMpns.filter( (e) => !datasheetFiles.has(e.mpn) && !libraryDatasheets.has(e.mpn) && !existingDatasheetStems.has(safeMpn(e.mpn)), ); if (missingIcs.length > 0) { const ok = window.confirm( `${missingIcs.length} IC${missingIcs.length !== 1 ? "s" : ""} without datasheets will not be analysed:\n\n${missingIcs.map((e) => ` ${e.mpn} (${e.refs.join(", ")})`).join("\n")}\n\nContinue anyway?`, ); if (!ok) return; } if (hasSimple) setStep("simple"); else if (hasPassives) setStep("passives"); } else if (step === "simple") { if (hasPassives) setStep("passives"); } }; const goBack = () => { setError(null); if (step === "columns") setStep("details"); else if (step === "subdesigns") setStep("columns"); else if (step === "lcsc-passives") { if (hasSubdesignChoice) setStep("subdesigns"); else setStep("columns"); } else if (step === "datasheets") { if (hasLcscPassives) setStep("lcsc-passives"); else if (hasSubdesignChoice) setStep("subdesigns"); else setStep("columns"); } else if (step === "simple") { if (hasIcs) setStep("datasheets"); else if (hasLcscPassives) setStep("lcsc-passives"); else if (hasSubdesignChoice) setStep("subdesigns"); else setStep("columns"); } else if (step === "passives") { if (hasSimple) setStep("simple"); else if (hasIcs) setStep("datasheets"); else if (hasLcscPassives) setStep("lcsc-passives"); else if (hasSubdesignChoice) setStep("subdesigns"); else setStep("columns"); } }; const handleCreate = async () => { setCreating(true); setError(null); const isRerunMode = rerunProject !== null; // "Early upload" projects are net-new drafts already on the server. // Two paths create one: the LCSC BOM resolver (Columns → Next) and the // EDIF sub-design picker (Details → Next). Don't reopen them, don't // re-create them, and skip whichever uploads already happened — the // per-file equality checks below decide what still needs uploading. const isEarlyUploaded = existingProjectId !== null && (bomUploadedEarly || netlistUploadedEarly); const projectAlreadyExists = isRerunMode || isEarlyUploaded; try { let project: Project; if (isRerunMode) { setProgress("Preparing rerun..."); project = await reopenProject(existingProjectId!); const trimmed = name.trim(); if (trimmed && trimmed !== project.name) { project = await renameProject(project.id, trimmed); } } else if (isEarlyUploaded) { setProgress("Continuing setup..."); project = await fetchProject(existingProjectId!); const trimmed = name.trim(); if (trimmed && trimmed !== project.name) { project = await renameProject(project.id, trimmed); } } else { setProgress("Creating project..."); project = await createProject(name.trim()); } // For new projects, delete the draft on BOM/netlist upload failure so // retries don't pile up orphan drafts. For reruns and early-upload // projects, the project already owns prior artifacts and should be // preserved on failure. try { if (!projectAlreadyExists || bomFile !== initialBomFile) { setProgress("Uploading BOM..."); await uploadBom( project.id, bomFile!, refCol || undefined, mpnCol || undefined, ); } if (!projectAlreadyExists || netlistFile !== initialNetlistFile) { setProgress("Uploading netlist..."); await uploadNetlist(project.id, netlistFile!); } } catch (uploadErr) { if (!projectAlreadyExists) { try { await deleteProject(project.id); } catch { // ignore cleanup failure — surface the original upload error } } throw uploadErr; } const entries = Array.from(datasheetFiles.entries()); for (let i = 0; i < entries.length; i++) { const [mpn, file] = entries[i]; setProgress( `Uploading datasheet ${i + 1}/${entries.length}: ${mpn}`, ); await uploadDatasheet(project.id, mpn, file); } // Upload simple component datasheets (per-MPN) const simpleEntries = Array.from(simpleDatasheetFiles.entries()); for (let i = 0; i < simpleEntries.length; i++) { const [mpn, file] = simpleEntries[i]; setProgress( `Uploading component datasheet ${i + 1}/${simpleEntries.length}: ${mpn}`, ); await uploadDatasheet(project.id, mpn, file); } // Upload passive datasheets (grouped by shared File to avoid duplicate uploads) const fileToMpns = new Map(); for (const [mpn, file] of passiveDatasheetFiles.entries()) { const list = fileToMpns.get(file) || []; list.push(mpn); fileToMpns.set(file, list); } const passiveUploads = Array.from(fileToMpns.entries()); for (let i = 0; i < passiveUploads.length; i++) { const [file, mpns] = passiveUploads[i]; const [primary, ...rest] = mpns; setProgress( `Uploading passive datasheet ${i + 1}/${passiveUploads.length}: ${file.name} (${mpns.length} part${mpns.length !== 1 ? "s" : ""})`, ); await uploadDatasheet(project.id, primary, file, rest.length > 0 ? rest : undefined); } setProgress("Starting pipeline..."); await startPipeline(project.id); onCreateProject(project); resetAndClose(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to create project"); setCreating(false); } }; // ---- Render ---- const isLastStep = step === activeSteps[activeSteps.length - 1]?.key; // True for the rerun/clone flows where the user is acting on an existing // project. Note: this is NOT the same as `existingProjectId !== null` — // the LCSC early-upload path also sets existingProjectId on a brand-new // draft, but the UI should still read as a fresh "Create Project" flow // (CTA label, step titles, etc.). const isRerun = rerunProject !== null; const isDraftEdit = isRerun && rerunProject?.status === "draft"; const canRunRerun = !estimate || balance === null ? true : balance >= estimate.credits_low; return ( { if (creating || disabled) return; if (v) setOpen(true); else resetAndClose(); }} > {disabled ? ( } > New Project Project limit reached ) : ( }> New Project )} {isRerun && !isDraftEdit ? `Rerun — ${activeSteps[stepIndex]?.label}` : activeSteps[stepIndex]?.label} {isDraftEdit ? `Continuing draft. Step ${stepIndex + 1} of ${activeSteps.length}` : isRerun ? `Re-running existing project. Step ${stepIndex + 1} of ${activeSteps.length}` : `Step ${stepIndex + 1} of ${activeSteps.length}`} {/* Progress bar */}
{activeSteps.map((s, i) => (
))}
{prefillLoading && (
Loading saved project data…
)} {prefillError && (
{prefillError}
)} {/* Surfaces the LCSC → MPN rewrite happening during the columns step. Shown while the early upload is in flight, plus an error row if it fails so the user can retry from the columns step. */} {earlyUploading && (
Resolving LCSC codes to manufacturer part numbers…
)} {earlyUploadError && (
{earlyUploadError}
)} {/* Step content */}
{/* ---- Step 1: Details ---- */} {step === "details" && (
setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && canAdvance() && goNext()} />

Requires columns: Designator, Manufacturer Part Number, and Comment (for passive values / mismatch detection).{" "} How to export →

{netlistError ? (

{netlistError}

) : netlistNetCount !== null ? (

{netlistNetCount} nets detected

) : netlistFile ? (

EDIF detected — net count will appear after upload.

) : (

PADS-PCB ASCII or EDIF 2.0.0. .asc / .net / .txt / .edn all work.{" "} How to export →

)}
)} {/* ---- Step 2: BOM Columns ---- */} {step === "columns" && csvData && (

Select which BOM columns contain the reference designators and manufacturer part numbers.

{lcscDetectedClient && (
LCSC part numbers detected. {" "} We'll resolve them to manufacturer part numbers via purple-parts when you click Next. Resistors, capacitors, and other passives will be auto-resolved on the next step.
)} {classification && (

{classification.totalRefs} components detected

{classification.icMpns.length} IC MPN {classification.icMpns.length !== 1 && "s"} {resolvedIcMpns.size > 0 && ( {" "} ({resolvedIcMpns.size} in library) )} {classification.passiveCount} passives {resolvedPassiveMpns.size > 0 && ( {" "} ({resolvedPassiveMpns.size} resolved) )}
{classification.icMpns.length > 0 && (
{classification.icMpns.map(({ mpn, refs }) => ( {mpn} ({refs.join(", ")}) ))}
)}
)}
)} {/* ---- Step 2.25: EDIF Sub-design Picker ---- */} {step === "subdesigns" && (

This EDIF file contains {edifSubDesigns.length} sub-designs.

{subdesignSuggestion?.kind === "clean" ? ( <> Only one sub-design contains parts from your BOM. We've pre-selected it; uncheck or include the others if needed. ) : ( <> Pick which sub-designs to review. Sub-designs with BOM coverage are pre-selected. )}

{edifSubDesigns.map((sd) => { const key = _subKey(sd.id); const checked = selectedSubdesignIds?.has(key) ?? false; const coverage = subdesignCoverage.get(key) ?? 0; const preview = sd.designators.slice(0, 8).join(", "); const more = sd.designators.length > 8 ? ` … +${sd.designators.length - 8} more` : ""; return ( ); })}
{selectedSubdesignIds && selectedSubdesignIds.size === 0 && (

No sub-designs selected — at least one is required.

)}
)} {/* ---- Step 2.5: LCSC Passive Resolve ---- */} {step === "lcsc-passives" && (

The BOM used LCSC part numbers. We're resolving each passive to typed specs (value, voltage, package) from its LCSC description.

{/* Manual retry — useful after a top-up or transient failures. Disabled while a batch is mid-flight. */}
{lcscOutOfCredits && (

Out of credits — top up to continue

Already-resolved passives are saved. After topping up, click Retry to finish the remaining rows. You can also skip this step and let the pipeline resolve them later.

)}
{lcscPassives.map(({ mpn, lcsc, refs, description }) => { const status = lcscStatus.get(lcsc) ?? "pending"; const error = lcscErrors.get(lcsc); const summary = lcscSummaries.get(lcsc); return (

{lcsc} → {mpn}

{refs.join(", ")} {description && ( <> {" · "} {description} )}

{status === "resolved" && summary && (

{summary}

)} {status === "failed" && error && (

{error}

)}
{status === "resolved" ? ( resolved ) : status === "resolving" ? ( ) : status === "failed" ? ( failed ) : ( pending )}
); })}

{(() => { const total = lcscPassives.length; const resolved = lcscPassives.filter( (p) => lcscStatus.get(p.lcsc) === "resolved", ).length; return `${resolved} of ${total} passive${total !== 1 ? "s" : ""} resolved`; })()} {" — you can proceed even with unresolved rows; the pipeline will retry them."}

)} {/* ---- Step 3: IC Datasheets ---- */} {step === "datasheets" && (
{checkingLibrary ? (

Checking extracted components…

) : (<>

Datasheets are fetched automatically and saved to the component library. The next board that uses the same MPN skips the download; after one review, pin-table extraction is skipped too. Upload a PDF for any row that fails.

{(() => { const failedCount = unresolvedIcMpns.filter( (e) => fetchStatus.get(e.mpn) === "failed" && !datasheetFiles.has(e.mpn) && !libraryDatasheets.has(e.mpn) && !existingDatasheetStems.has(safeMpn(e.mpn)), ).length; if (failedCount === 0) return null; return (

{failedCount} datasheet{failedCount !== 1 ? "s" : ""} couldn't download automatically.

Vendor sites sometimes block automated downloads from this server. Retry, or open a link on the failed row in this browser, save the PDF, then use the PDF upload button.

); })()}
{unresolvedIcMpns.map(({ mpn, refs }) => { const file = datasheetFiles.get(mpn); const inLibrary = !file && libraryDatasheets.has(mpn); const inProject = !file && !inLibrary && existingDatasheetStems.has(safeMpn(mpn)); const mpnFetchStatus = fetchStatus.get(mpn); const mpnFetchError = fetchErrors.get(mpn); const mpnFetchUrl = fetchUrls.get(mpn); const mpnFetchUrls = fetchUrlLists.get(mpn) ?? (mpnFetchUrl ? [mpnFetchUrl] : []); // If this MPN came from an LCSC id, show the source id as // a muted prefix so the user can cross-check against // their BOM. Hidden when the BOM already had real MPNs. const lcscSource = mpnToLcsc.get(mpn); return (

{lcscSource && ( {lcscSource} → )} {mpn}

{mpnFetchStatus === "failed" && mpnFetchUrls.length > 0 && ( )} {mpnFetchStatus !== "failed" && mpnFetchUrl && ( e.stopPropagation()} > {mpnFetchUrl} )}

{refs.join(", ")}

{mpnFetchStatus === "failed" && mpnFetchError && (

{mpnFetchError}

)}
{file ? (
{file.name} {fetchSources.get(mpn) && ( {fetchSources.get(mpn)} )}
) : inLibrary ? ( in library ) : inProject ? ( uploaded ) : mpnFetchStatus === "fetching" ? ( ) : ( )}
); })}

{datasheetFiles.size + unresolvedIcMpns.filter(e => !datasheetFiles.has(e.mpn) && (libraryDatasheets.has(e.mpn) || existingDatasheetStems.has(safeMpn(e.mpn)))).length} of {unresolvedIcMpns.length} datasheets ready {resolvedIcMpns.size > 0 && ( {" "} ({resolvedIcMpns.size} fully resolved) )}

)}
)} {/* ---- Step 3.5: Simple Component Datasheets (Optional) ---- */} {step === "simple" && (

Resolve component specs automatically, or upload datasheets. These are optional.

{(() => { const failedCount = unresolvedSimpleGroups .flatMap((g) => g.mpns) .filter( (e) => fetchStatus.get(e.mpn) === "failed" && !simpleDatasheetFiles.has(e.mpn) && !libraryDatasheets.has(e.mpn) && !existingDatasheetStems.has(safeMpn(e.mpn)) && resolveStatus.get(e.mpn) !== "resolved", ).length; if (failedCount === 0) return null; return (

{failedCount} datasheet{failedCount !== 1 ? "s" : ""} couldn't download automatically.

Vendor sites sometimes block automated downloads or return a non-PDF error page. These components are optional, but if you want a full review: open a link in this browser, save the PDF, then use the PDF upload button.

); })()}
{unresolvedSimpleGroups.map((group) => (

{group.label} {group.label === "Connectors" && ( — datasheets are truly{" "} optional )}

{group.mpns.map(({ mpn, refs }) => { const file = simpleDatasheetFiles.get(mpn); const inLibrary = !file && libraryDatasheets.has(mpn); const rStatus = resolveStatus.get(mpn); const rError = resolveErrors.get(mpn); const sFetchStatus = fetchStatus.get(mpn); const sFetchError = fetchErrors.get(mpn); const sFetchUrl = fetchUrls.get(mpn); const sFetchUrls = fetchUrlLists.get(mpn) ?? (sFetchUrl ? [sFetchUrl] : []); const isResolved = rStatus === "resolved"; const inProject = !file && !inLibrary && !isResolved && existingDatasheetStems.has(safeMpn(mpn)); return (

{mpn}

{sFetchStatus === "failed" && sFetchUrls.length > 0 && ( )}

{refs.join(", ")}

{rStatus === "failed" && rError && (

{rError}

)} {sFetchStatus === "failed" && sFetchError && (

{sFetchError}

)}
{file ? (
{file.name}
) : inLibrary ? ( in library ) : isResolved ? ( auto-resolved ) : inProject ? ( uploaded ) : rStatus === "resolving" || sFetchStatus === "fetching" ? ( ) : ( )}
); })}
))}

{(() => { const totalUnresolved = unresolvedSimpleGroups.reduce((sum, g) => sum + g.mpns.length, 0); const autoResolvedCount = Array.from(resolveStatus.values()).filter(s => s === "resolved").length; const readyCount = simpleDatasheetFiles.size + autoResolvedCount; return `${readyCount} of ${totalUnresolved} components ready (all optional)`; })()} {resolvedSimpleMpns.size > 0 && ( {" "} ({resolvedSimpleMpns.size} already in library) )}

)} {/* ---- Step 4: Passive Datasheets (Optional) ---- */} {step === "passives" && (

Resolve component specs automatically, or upload datasheets. These are optional.

{unresolvedPassiveGroups.map((group) => (

{group.label}

{group.mpns.map(({ mpn, refs }) => { const file = passiveDatasheetFiles.get(mpn); const inLibrary = !file && resolvedPassiveMpns.has(mpn); const rStatus = resolveStatus.get(mpn); const rError = resolveErrors.get(mpn); const pFetchStatus = fetchStatus.get(mpn); const pFetchError = fetchErrors.get(mpn); const isResolved = rStatus === "resolved"; const inProject = !file && !inLibrary && !isResolved && existingDatasheetStems.has(safeMpn(mpn)); return (

{mpn}

{refs.join(", ")}

{rStatus === "failed" && rError && (

{rError}

)} {pFetchStatus === "failed" && pFetchError && (

{pFetchError}

)}
{file ? (
{file.name}
) : inLibrary ? ( in library ) : isResolved ? ( auto-resolved ) : inProject ? ( uploaded ) : rStatus === "resolving" || pFetchStatus === "fetching" ? ( ) : ( )}
); })}
))}

{(() => { const totalUnresolved = unresolvedPassiveGroups.reduce((sum, g) => sum + g.mpns.length, 0); const autoResolvedCount = unresolvedPassiveGroups .flatMap((g) => g.mpns) .filter((e) => resolveStatus.get(e.mpn) === "resolved").length; const readyCount = passiveDatasheetFiles.size + autoResolvedCount; return `${readyCount} of ${totalUnresolved} components ready (all optional)`; })()} {resolvedPassiveMpns.size > 0 && ( {" "} ({resolvedPassiveMpns.size} already in library) )}

)} {/* ---- Step 5: Power Sources (Optional) ---- */}
{/* Error */} {error && (
{error}
)} {/* Footer */}
{step !== "details" && !creating && ( )}
{creating ? ( ) : isLastStep ? ( ) : ( )}
{isLastStep && isRerun && (
{estimateLoading ? ( Estimating… ) : estimate && balance !== null ? ( <> Balance:{" "} {balance.toFixed(2)} Est:{" "} {estimate.credits_low.toFixed(2)}–{estimate.credits_high.toFixed(2)} {" "} credits {!canRunRerun && ( Need {estimate.credits_low.toFixed(2)} to run )} ) : null}
)}
); }