"use client"; import { use, useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { fetchProject, fetchProjectLogs, fetchBomSummary, fetchDerating, fetchCollaborators, addCollaborator, removeCollaborator, makeCollaboratorOwner, startPipeline, startPlacementPipeline, reprocessPipeline, resumePipeline, fetchPipelineEstimate, } from "@/lib/api"; import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog"; import { PausedRunBanner } from "@/components/billing/paused-run-banner"; import type { Project, SkippedComponent, ApiLogEntry, BomSummaryRow, DeratingRow, DeratingSettings, Collaborator, CostEstimate } from "@/lib/types"; import { ArrowRight, Play, RotateCcw, AlertTriangle, ExternalLink, X, UserPlus, Trash2, Crown, Coins, OctagonX, Copy, Check, Upload, LayoutGrid, } from "lucide-react"; import { useOptionalUser } from "@/hooks/use-optional-auth"; import { ImpedancePanel } from "@/components/project/impedance-panel"; import { PcbUploadButton } from "@/components/project/pcb-upload"; import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet"; export default function ProjectDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = use(params); const router = useRouter(); const [project, setProject] = useState(null); const [logs, setLogs] = useState([]); const [bomRows, setBomRows] = useState([]); const [deratingRows, setDeratingRows] = useState([]); const [deratingSettings, setDeratingSettings] = useState(() => { if (typeof window === "undefined") return { ceramic: 50, tantalum: 50, electrolytic: 50 }; try { const stored = localStorage.getItem(`pinscopex:derating-settings:${id}`); return stored ? JSON.parse(stored) : { ceramic: 50, tantalum: 50, electrolytic: 50 }; } catch { return { ceramic: 50, tantalum: 50, electrolytic: 50 }; } }); const [manualVoltages, setManualVoltages] = useState>(() => { if (typeof window === "undefined") return {}; try { const stored = localStorage.getItem(`pinscopex:derating-overrides:${id}`); return stored ? JSON.parse(stored) : {}; } catch { return {}; } }); const [pdfState, setPdfState] = useState<{ open: boolean; mpn: string | null; }>({ open: false, mpn: null }); // Persist derating settings to localStorage useEffect(() => { localStorage.setItem(`pinscopex:derating-settings:${id}`, JSON.stringify(deratingSettings)); }, [deratingSettings, id]); // Persist manual voltage overrides to localStorage useEffect(() => { localStorage.setItem(`pinscopex:derating-overrides:${id}`, JSON.stringify(manualVoltages)); }, [manualVoltages, id]); const reload = useCallback(() => { fetchProject(id).then(setProject); fetchProjectLogs(id).then(setLogs); fetchBomSummary(id).then(setBomRows); fetchDerating(id).then(setDeratingRows); }, [id]); useEffect(() => { reload(); }, [reload]); // Redirect to progress page if pipeline is running or queued useEffect(() => { if (project?.status === "running" || project?.status === "queued") { router.replace(`/project/${id}/progress`); } }, [project?.status, id, router]); // Placement progress page when placement is active useEffect(() => { if ( project?.placementStatus === "running" || project?.placementStatus === "queued" ) { router.replace(`/project/${id}/placement`); } }, [project?.placementStatus, id, router]); const searchParams = useSearchParams(); const tab = searchParams.get("tab") ?? "bom"; const [starting, setStarting] = useState(false); const [startingPlacement, setStartingPlacement] = useState(false); const [estimate, setEstimate] = useState(null); const [rerunProject, setRerunProject] = useState(null); const canRun = Boolean(project?.hasBom && project?.hasNetlist); const canReprocess = project?.status === "complete" || project?.status === "error" || project?.status === "cancelled"; const canStartFresh = project?.status !== "complete" && project?.status !== "paused_insufficient_credits" && !canReprocess; // Pull a cost estimate when the project is ready to run, so we can show // the "~X credits" hint under the button. No blocking — the user can // start the run even if the balance is below the estimate. useEffect(() => { if (!canRun || !canStartFresh) { setEstimate(null); return; } let cancelled = false; fetchPipelineEstimate(id) .then((est) => { if (!cancelled) setEstimate(est); }) .catch(() => { if (!cancelled) setEstimate(null); }); return () => { cancelled = true; }; }, [id, canRun, canStartFresh]); if (!project) return null; const hasSkipped = project.skippedComponents && project.skippedComponents.length > 0; const isPaused = project.status === "paused_insufficient_credits"; const handleRunPipeline = async () => { if (!canRun) return; setStarting(true); try { await startPipeline(id); router.push(`/project/${id}/progress`); } catch (e) { setStarting(false); alert(e instanceof Error ? e.message : "Failed to start pipeline"); } }; const handleResumeRun = async () => { setStarting(true); try { await resumePipeline(id); router.push(`/project/${id}/progress`); } catch (e) { setStarting(false); alert(e instanceof Error ? e.message : "Failed to resume pipeline"); } }; const handleReprocess = async (mode: "failed" | "all") => { if (!canRun) return; setStarting(true); try { await reprocessPipeline(id, mode); router.push(`/project/${id}/progress`); } catch (e) { setStarting(false); alert(e instanceof Error ? e.message : "Failed to reprocess"); } }; const analysisBusy = project?.status === "running" || project?.status === "queued"; const placementBusy = project?.placementStatus === "running" || project?.placementStatus === "queued"; const canStartPlacement = Boolean(canRun) && !analysisBusy && !placementBusy; const handlePlacement = async () => { if (!canStartPlacement) return; setStartingPlacement(true); try { await startPlacementPipeline(id); router.push(`/project/${id}/placement`); } catch (e) { setStartingPlacement(false); alert(e instanceof Error ? e.message : "Failed to start placement"); } }; const hasFailedReviews = Boolean(hasSkipped); return (

{project.name}

{new Date(project.created).toLocaleDateString()} {project.hasPcb ? ( · PCB uploaded ) : ( · no PCB )} {typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && ( ${project.totalCostUsd.toFixed(4)} )}

{project.status !== "running" && project.status !== "queued" && ( )} {project.status}
{isPaused && ( )} {project.status === "error" && ( )} {project.status === "complete" ? (
Validation complete
{hasFailedReviews && ( )} {project.placementStatus === "complete" && ( )}
) : isPaused ? ( Paused — waiting for credits. Use the banner above to resume. ) : (
{(project.status === "error" || project.status === "cancelled") && canRun ? ( <> ) : ( )} {!canRun && ( Upload BOM and netlist to enable )}
{canRun && estimate && estimate.review_ic_count > 0 && (
≈ ${estimate.api_cost_low.toFixed(2)}–${estimate.api_cost_high.toFixed(2)} · {estimate.review_ic_count} IC {estimate.review_ic_count === 1 ? "" : "s"} to review
)}
)}
{tab === "bom" && ( setPdfState({ open: true, mpn })} /> )} {tab === "derating" && ( { setManualVoltages((prev) => { if (voltage === null) { const next = { ...prev }; delete next[designator]; return next; } return { ...prev, [designator]: voltage }; }); }} /> )} {tab === "impedance" && ( )} {tab === "logs" && ( )} {tab === "settings" && (
KiCad PCB

{project.hasPcb ? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0." : "No board yet. Schematic review does not need one. Layout checks and net Z0 do."}

)} setPdfState((s) => ({ ...s, open }))} projectId={id} mpn={pdfState.mpn} initialPage={1} /> { setRerunProject(null); reload(); }} onCreateProject={(p) => { setRerunProject(null); router.push(`/project/${p.id}/progress`); }} />
); } const STAGE_LABELS: Record = { ic_extraction: "IC Extraction", simple_extraction: "Specs Extraction", passive_extraction: "Passive Extraction", passive_pattern_load: "Pattern Loading", passive_resolve: "Passive Resolution", passive_specs: "Passive Specs", graph_build: "Graph Build", validation: "Datasheet Review", simple_digikey_resolve: "DigiKey Auto-Resolve", }; const LOG_STAGE_LABELS: Record = { pintable: "Pin Table", rules: "Rules", specs: "Specs", pattern: "Pattern", validation: "Validation", }; function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(1)}s`; } function formatTokens(n: number): string { if (n < 1000) return String(n); return `${(n / 1000).toFixed(1)}k`; } function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) { if (logs.length === 0) { return ( API Calls

No API call logs yet. Run the pipeline to generate logs.

); } const totalInput = logs.reduce((s, l) => s + l.input_tokens, 0); const totalOutput = logs.reduce((s, l) => s + l.output_tokens, 0); const totalDuration = logs.reduce((s, l) => s + l.duration_ms, 0); const totalCost = logs.reduce((s, l) => s + (l.cost_usd ?? 0), 0); return ( API Calls {logs.length}
{formatTokens(totalInput)} input tokens {formatTokens(totalOutput)} output tokens {formatDuration(totalDuration)} total {totalCost > 0 && ( ${totalCost.toFixed(4)} )}
{logs.map((log, i) => (
{log.identifier} {LOG_STAGE_LABELS[log.stage] ?? log.stage} {log.skill_id && ( skill )} {log.turns && log.turns > 1 && ( {log.turns} turns )}
{formatTokens(log.input_tokens)} in {formatTokens(log.output_tokens)} out {formatDuration(log.duration_ms)} {log.model} {log.cost_usd != null && ( ${log.cost_usd.toFixed(4)} )}
))}
); } const SPEC_LABELS: Record = { value_formatted: "Value", tolerance: "Tolerance", package: "Package", power_rating_w: "Power", voltage_rating_v: "Voltage", dielectric: "Dielectric", current_rating_a: "Current", dcr_ohms: "DCR", // discrete forward_voltage_v: "Vf", reverse_voltage_v: "Vr", forward_current_a: "If", zener_voltage_v: "Vz", zener_impedance_ohm: "Zzt", standoff_voltage_v: "Vrwm", clamping_voltage_v: "Vclamp", peak_pulse_current_a: "Ipp", vds_max_v: "Vds", vce_max_v: "Vce", id_max_a: "Id", ic_max_a: "Ic", rds_on_ohm: "Rds(on)", vgs_th_v: "Vgs(th)", qg_c: "Qg", hfe: "hFE", color: "Color", power_dissipation_w: "Pd", // crystal frequency_hz: "Freq", load_capacitance_f: "CL", esr_ohm: "ESR", // connector pin_count: "Pins", // general turns_ratio: "Turns", voltage_primary_v: "Vpri", voltage_secondary_v: "Vsec", breaking_capacity_a: "Breaking", }; function BomSummaryTable({ rows, onViewDatasheet, }: { rows: BomSummaryRow[]; onViewDatasheet: (mpn: string) => void; }) { if (rows.length === 0) { return ( Bill of Materials

No BOM summary yet. Run the pipeline to generate.

); } return ( Bill of Materials {rows.length} parts
{rows.map((row, i) => ( ))}
MPN Designators Value Category Specs Datasheet
{row.mpn ?? } {row.designators.join(", ")} {row.value} {row.category ? ( {row.category} ) : ( )} {row.description ? ( {row.description} ) : row.specs ? ( {Object.entries(row.specs).map(([k, v]) => ( {SPEC_LABELS[k] ?? k}:{" "} {String(v)} ))} ) : ( "—" )} {row.hasDatasheet && row.mpn ? ( ) : ( )}
); } function deratingStatus( row: DeratingRow, settings: DeratingSettings, manualVoltage: number | undefined, ): "pass" | "fail" | "unknown" { const rated = row.rated_voltage_v; const operating = manualVoltage ?? row.operating_voltage_v; if (rated == null || operating == null) return "unknown"; const category = row.dielectric_category ?? "ceramic"; const pct = settings[category] / 100; const threshold = rated * (1 - pct); return operating <= threshold ? "pass" : "fail"; } const STATUS_ROW_STYLES: Record = { pass: "bg-emerald-500/10", fail: "bg-rose-500/10", unknown: "", }; const CATEGORY_LABELS: Record = { ceramic: "Ceramic", tantalum: "Tantalum", electrolytic: "Electrolytic", }; function DeratingTable({ rows, settings, onSettingsChange, manualVoltages, onManualVoltageChange, }: { rows: DeratingRow[]; settings: DeratingSettings; onSettingsChange: (s: DeratingSettings) => void; manualVoltages: Record; onManualVoltageChange: (designator: string, voltage: number | null) => void; }) { const [editingCell, setEditingCell] = useState(null); const [editValue, setEditValue] = useState(""); if (rows.length === 0) { return ( Capacitor Voltage Derating

No derating data yet. Run the pipeline to generate.

); } const passCount = rows.filter( (r) => deratingStatus(r, settings, manualVoltages[r.designator]) === "pass", ).length; const failCount = rows.filter( (r) => deratingStatus(r, settings, manualVoltages[r.designator]) === "fail", ).length; return (
{/* Settings card */}
Derating % {(["ceramic", "tantalum", "electrolytic"] as const).map((cat) => ( ))}
{/* Table */} Capacitor Voltage Derating {rows.length} caps {passCount > 0 && ( {passCount} pass )} {failCount > 0 && ( {failCount} fail )}

C_eff is an empirical DC-bias stima (C0G/X7R/X5R), not a Murata lot curve.

{rows.map((row) => { const manual = manualVoltages[row.designator]; const status = deratingStatus(row, settings, manual); const operatingV = manual ?? row.operating_voltage_v; const source = manual != null ? "user" : row.operating_voltage_source; const isEditing = editingCell === row.designator; // Compute margin let margin: string | null = null; if (row.rated_voltage_v != null && operatingV != null) { const cat = row.dielectric_category ?? "ceramic"; const threshold = row.rated_voltage_v * (1 - settings[cat] / 100); const pct = ((threshold - operatingV) / threshold) * 100; margin = `${pct >= 0 ? "+" : ""}${pct.toFixed(0)}%`; } return ( ); })}
Designator MPN Value C_eff Type Net+ Net− Rated V Operating V Source Margin
{row.designator} {row.mpn ?? } {row.value_formatted ?? } {row.c_eff_formatted ? ( {row.c_eff_formatted} {row.dc_bias_model === "stima" && ( stima )} ) : ( )} {row.dielectric_category ? ( {row.dielectric_category} ) : ( )} {row.net_plus ?? } {row.net_minus ?? } {row.rated_voltage_v != null ? ( `${row.rated_voltage_v}V` ) : ( )} {isEditing ? ( setEditValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { const v = parseFloat(editValue); if (!isNaN(v) && v >= 0) { onManualVoltageChange(row.designator, v); } setEditingCell(null); } if (e.key === "Escape") setEditingCell(null); }} onBlur={() => { const v = parseFloat(editValue); if (!isNaN(v) && v >= 0) { onManualVoltageChange(row.designator, v); } setEditingCell(null); }} className="w-16 rounded border border-blue-500 bg-background px-1.5 py-0.5 text-xs font-mono text-right focus:outline-none" /> ) : ( { setEditingCell(row.designator); setEditValue( String(manual ?? row.operating_voltage_v ?? ""), ); }} > {operatingV != null ? ( `${operatingV}V` ) : ( click to set )} {manual != null && ( )} )} {source === "user" ? ( user ) : source ? ( {source} ) : ( )} {margin != null ? ( {margin} ) : ( )}
); } function CollaboratorsSection({ projectId }: { projectId: string }) { const { user } = useOptionalUser(); const [collaborators, setCollaborators] = useState([]); const [ownerUserId, setOwnerUserId] = useState(""); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(true); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); const isOwner = ownerUserId ? ownerUserId === user?.id || ownerUserId === "local" : false; const isAdmin = user?.isAdmin ?? false; const reload = useCallback(() => { setLoading(true); fetchCollaborators(projectId) .then((data) => { setCollaborators(data.collaborators); setOwnerUserId(data.owner_user_id); }) .finally(() => setLoading(false)); }, [projectId]); useEffect(() => { reload(); }, [reload]); const handleAdd = async () => { if (!email.trim()) return; setAdding(true); setError(null); try { await addCollaborator(projectId, email.trim()); setEmail(""); reload(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to add collaborator"); } finally { setAdding(false); } }; const handleRemove = async (userId: string) => { setError(null); try { await removeCollaborator(projectId, userId); setCollaborators((prev) => prev.filter((c) => c.user_id !== userId)); } catch (e) { setError(e instanceof Error ? e.message : "Failed to remove collaborator"); } }; const handleMakeOwner = async (userId: string) => { setError(null); try { await makeCollaboratorOwner(projectId, userId); reload(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to transfer ownership"); } }; return ( Collaborators {collaborators.length > 0 && ( {collaborators.length} )} {isOwner && (
setEmail(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }} className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500" />
)} {error && (

{error}

)} {loading ? (

Loading...

) : collaborators.length === 0 ? (

No collaborators yet.{isOwner ? " Add team members by email to give them access to this project." : ""}

) : (
{collaborators.map((collab) => (
{collab.image_url ? ( ) : (
{(collab.name?.[0] || collab.email?.[0] || "?").toUpperCase()}
)}

{collab.name || "Unknown"}

{collab.role === "owner" && ( Owner )}
{collab.email && (

{collab.email}

)}
{isAdmin && collab.role !== "owner" && ( )} {(isOwner || isAdmin) && collab.role !== "owner" && ( )}
))}
)}
); } function SkippedComponentsSection({ skipped, }: { skipped?: SkippedComponent[]; }) { if (!skipped || skipped.length === 0) { return ( Skipped Components

No components were skipped during analysis.

); } return ( Skipped Components {skipped.length}

These components were skipped during analysis due to errors. The rest of the pipeline continued without them.

{skipped.map((item, i) => (
{item.identifier} {STAGE_LABELS[item.stage] ?? item.stage}

{item.error}

))}
); } function ReportVersionSection({ pinscopeVersion, }: { pinscopeVersion?: string | null; }) { if (!pinscopeVersion) return null; return ( Report Version

Generated with Pinscope

v{pinscopeVersion}
); } function PipelineErrorBanner({ projectId, message, }: { projectId: string; message: string | null; }) { const [copied, setCopied] = useState(false); const detail = message ?? "Unknown error — no details were recorded."; const shareText = `PinscopeX project ${projectId} failed: ${detail}`; const handleCopy = async () => { try { await navigator.clipboard.writeText(shareText); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // clipboard blocked — fall back to no-op; user can still select the text } }; return (

Pipeline run failed

              {detail}
            

Project ID: {projectId}. Try running again — if the error persists,{" "} contact support {" "} and include the error above.

); }