Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e872cfbc3 | ||
|
|
3046c4aeee | ||
|
|
b32d581d27 | ||
|
|
d5fff7c6dd |
@@ -2,10 +2,16 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.83.0 — 2026-09-22 — USB-C vSafe still UNKNOWN (no USB-IF PD on file)
|
||||
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
|
||||
|
||||
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.75–5.5 V stays not-vSafe. HDMI 1.4 TMDS stays UNKNOWN.
|
||||
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
|
||||
|
||||
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.75–5.5 V stays not-vSafe.
|
||||
|
||||
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
|
||||
- [Changed] Findings tree grouping + protocol section inside the tree.
|
||||
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
|
||||
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
|
||||
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
|
||||
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
|
||||
|
||||
|
||||
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCredits } from "@/components/billing/credits-context";
|
||||
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
|
||||
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
|
||||
import { ProjectCard } from "@/components/dashboard/project-card";
|
||||
import { ProjectsTable } from "@/components/dashboard/projects-table";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -23,13 +21,10 @@ import {
|
||||
fetchProjects,
|
||||
reconcileCheckoutSession,
|
||||
} from "@/lib/api";
|
||||
import type { CreditSnapshot, Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project } from "@/lib/types";
|
||||
|
||||
type LayoutKind = "cards" | "table";
|
||||
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
|
||||
|
||||
const LAYOUT_KEY = "periscopex:dashboard:view";
|
||||
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -62,28 +57,9 @@ function DashboardBody() {
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
|
||||
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
|
||||
const [layout, setLayout] = useState<LayoutKind>("cards");
|
||||
|
||||
const ordered = useMemo(() => newestFirst(projects), [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(LAYOUT_KEY);
|
||||
if (stored === "cards" || stored === "table") setLayout(stored);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}, []);
|
||||
|
||||
function persistLayout(next: LayoutKind) {
|
||||
setLayout(next);
|
||||
try {
|
||||
localStorage.setItem(LAYOUT_KEY, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function stripCheckoutQuery() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("topup");
|
||||
@@ -208,7 +184,7 @@ function DashboardBody() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
|
||||
<div className="mx-auto w-full max-w-[720px] flex-1 px-6 py-6">
|
||||
<OnboardingSurvey />
|
||||
{bannerVisible && topupPhase !== "dismissed" && (
|
||||
<TopupBanner
|
||||
@@ -217,11 +193,10 @@ function DashboardBody() {
|
||||
onDismiss={hideBanner}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Projects</h1>
|
||||
<CreditsLine credits={credits} />
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Progetti"
|
||||
meta={credits ? `${credits.balance.toFixed(2)} crediti` : "Un progetto, una finestra."}
|
||||
actions={
|
||||
<CreateProjectDialog
|
||||
rerunProject={rerunProject}
|
||||
onRerunDone={() => setRerunProject(null)}
|
||||
@@ -229,104 +204,33 @@ function DashboardBody() {
|
||||
onCloneAsNewDone={() => setCloneAsNewProject(null)}
|
||||
onCreateProject={upsertProject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!loading && projects.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 mb-4">
|
||||
<div className="inline-flex rounded-lg border border-input p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => persistLayout("cards")}
|
||||
aria-pressed={layout === "cards"}
|
||||
title="Card view"
|
||||
className={cn(
|
||||
"p-1 rounded-md transition-colors",
|
||||
layout === "cards"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => persistLayout("table")}
|
||||
aria-pressed={layout === "table"}
|
||||
title="Table view"
|
||||
className={cn(
|
||||
"p-1 rounded-md transition-colors",
|
||||
layout === "table"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 rounded-[11px]" />
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No projects yet. Create one to get started.
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
|
||||
<p className="text-[15px] font-medium tracking-tight">Nessun progetto</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
Crea un progetto e carica netlist, BOM e PCB. L’esame parte da lì.
|
||||
</p>
|
||||
) : layout === "table" ? (
|
||||
</div>
|
||||
) : (
|
||||
<ProjectsTable
|
||||
projects={ordered}
|
||||
onDeleted={dropProject}
|
||||
onRerun={setRerunProject}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{ordered.map((p) => (
|
||||
<ProjectCard
|
||||
key={p.id}
|
||||
project={p}
|
||||
onDeleted={() => dropProject(p.id)}
|
||||
onRerun={setRerunProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreditsLine({ credits }: { credits: CreditSnapshot | null }) {
|
||||
if (!credits) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Schematic validation projects
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{credits.balance.toFixed(2)} credits
|
||||
{" · "}
|
||||
<Link
|
||||
href="/billing"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
buy more
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link
|
||||
href="/credits"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
ledger
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function TopupBanner({
|
||||
phase,
|
||||
detail,
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function AppShellLayout({
|
||||
<AuthGate>
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col overflow-auto">
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
|
||||
import { ReportSummary } from "@/components/report/report-summary";
|
||||
import { FindingsList } from "@/components/report/findings-list";
|
||||
import { FindingFocusView } from "@/components/report/finding-focus-view";
|
||||
import { ProtocolSection } from "@/components/report/protocol-section";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Toast, useToast } from "@/components/ui/toast";
|
||||
@@ -240,18 +240,14 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
|
||||
className={cn("mx-auto w-full px-6 py-6", focus ? "max-w-[1920px]" : "max-w-[720px]")}
|
||||
>
|
||||
<div className={cn("space-y-6", focus && "hidden")}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Validation Report</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{report.findings.length} findings ·{" "}
|
||||
{new Date(report.timestamp).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("space-y-5", focus && "hidden")}>
|
||||
<PageHeader
|
||||
title={projectName || "Esame"}
|
||||
meta={`${report.findings.length} findings · ${new Date(report.timestamp).toLocaleDateString()}`}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -297,14 +293,14 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
Sign release
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ReportSummary
|
||||
summary={report.summary}
|
||||
reviewedCount={reviewedCount}
|
||||
creditsSpent={creditsSpent}
|
||||
totalCostUsd={totalCostUsd}
|
||||
/>
|
||||
<ProtocolSection section={report.protocol_certification} />
|
||||
{failedReviews > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
@@ -344,12 +340,12 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{report.summary.total === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
|
||||
<p className="text-sm font-medium">No findings</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No IC datasheets were available for review. Upload datasheets for each IC
|
||||
on the project page and re-run the pipeline to get findings.
|
||||
{report.summary.total === 0 &&
|
||||
!(report.protocol_certification?.recognized_instances?.length) ? (
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
|
||||
<p className="text-[15px] font-medium tracking-tight">Nessun finding</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
Carica i datasheet degli IC sulla pagina progetto e rilancia l’esame.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -370,6 +366,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={handleReviewSaved}
|
||||
protocolSection={report.protocol_certification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
|
||||
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
|
||||
@theme inline {
|
||||
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
|
||||
system-ui, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, monospace;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-headline: var(--font-dm-serif-display), Georgia, serif;
|
||||
--font-headline: var(--font-sans);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -53,15 +54,15 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--radius: 0.6875rem;
|
||||
--background: #f5f5f7;
|
||||
--foreground: #1d1d1f;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1d1d1f;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1d1d1f;
|
||||
--primary: #007aff;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
@@ -69,22 +70,22 @@
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--input: rgba(0, 0, 0, 0.1);
|
||||
--ring: #007aff;
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar: #f5f5f7;
|
||||
--sidebar-foreground: #1d1d1f;
|
||||
--sidebar-primary: #007aff;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: rgba(0, 122, 255, 0.12);
|
||||
--sidebar-accent-foreground: #007aff;
|
||||
--sidebar-border: rgba(0, 0, 0, 0.1);
|
||||
--sidebar-ring: #007aff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -129,7 +130,8 @@
|
||||
@apply font-sans;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
|
||||
@@ -13,22 +12,6 @@ import {
|
||||
TWITTER_HANDLE,
|
||||
} from "@/lib/site";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const dmSerifDisplay = DM_Serif_Display({
|
||||
variable: "--font-dm-serif-display",
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
@@ -109,7 +92,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
|
||||
className="h-full antialiased"
|
||||
>
|
||||
<body className="h-full">
|
||||
<ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
meta,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
meta?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-end justify-between gap-3 pb-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[32px] font-semibold leading-[1.1] tracking-tight text-neutral-900 dark:text-neutral-50">
|
||||
{title}
|
||||
</h1>
|
||||
{meta ? (
|
||||
<p className="mt-1 text-[13px] text-neutral-500 tabular-nums">{meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -4,20 +4,15 @@ import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
CircuitBoard,
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
LayoutDashboard,
|
||||
Folder,
|
||||
Library,
|
||||
Loader2,
|
||||
MessageSquareWarning,
|
||||
Ruler,
|
||||
ScrollText,
|
||||
ScanLine,
|
||||
Settings,
|
||||
Shield,
|
||||
TableProperties,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
@@ -72,15 +67,15 @@ export function Sidebar() {
|
||||
const isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return (
|
||||
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
<aside className="flex w-[196px] shrink-0 flex-col border-r border-black/10 bg-[#f5f5f7]/90 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90">
|
||||
<div className="border-b border-black/10 px-3 py-3 dark:border-white/10">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<PeriscopeMark className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold tracking-tight">Periscope</span>
|
||||
<span className="text-[13px] font-semibold tracking-tight">Periscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{projectId ? (
|
||||
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
|
||||
<ProjectNav
|
||||
@@ -95,23 +90,23 @@ export function Sidebar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border">
|
||||
<div className="border-t border-black/10 dark:border-white/10">
|
||||
<SidebarCredits />
|
||||
<div className="px-2 py-1.5 border-b border-border/60">
|
||||
<div className="border-b border-black/[0.06] px-2 py-1.5 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
className="flex w-full items-center gap-2 rounded-[8px] px-2 py-1.5 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900"
|
||||
>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<SidebarUserButton />
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Link>
|
||||
@@ -125,36 +120,35 @@ export function Sidebar() {
|
||||
|
||||
function navItemClass(active: boolean) {
|
||||
return cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
"flex items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] transition-colors duration-150 ease-out",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
? "bg-[#007aff]/12 text-[#007aff]"
|
||||
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
|
||||
return (
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5">
|
||||
<nav className="flex-1 space-y-0.5 px-2 py-3">
|
||||
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Projects
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Progetti
|
||||
</Link>
|
||||
<Link href="/library" className={navItemClass(pathname === "/library")}>
|
||||
<Library className="h-4 w-4" />
|
||||
Library
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Libreria
|
||||
</Link>
|
||||
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
|
||||
<MessageSquareWarning className="h-4 w-4" />
|
||||
My Feedback
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
@@ -162,19 +156,18 @@ function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean
|
||||
}
|
||||
|
||||
type NavItem =
|
||||
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
|
||||
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
|
||||
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
|
||||
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
|
||||
|
||||
const PROJECT_NAV_ITEMS: NavItem[] = [
|
||||
const PRIMARY_NAV: NavItem[] = [
|
||||
{ type: "tab", tab: "bom", label: "Esame", icon: ScanLine },
|
||||
{ type: "route", path: "/report", label: "Protocolli", icon: BookOpen },
|
||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
|
||||
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
||||
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
|
||||
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
|
||||
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
|
||||
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
|
||||
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
|
||||
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
const SECONDARY_NAV: NavItem[] = [
|
||||
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
|
||||
{ type: "tab", tab: "settings", label: "Impostazioni", icon: Settings },
|
||||
];
|
||||
|
||||
function NavLink({
|
||||
@@ -220,8 +213,11 @@ function ProjectNav({
|
||||
const isRunning = project?.status === "running";
|
||||
const isOnProgress = pathname === `${base}/progress`;
|
||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||
const onReport = pathname === `${base}/report`;
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.label === "Protocolli") return onReport;
|
||||
if (item.label === "Report") return onReport;
|
||||
if (item.type === "route") {
|
||||
return pathname === `${base}${item.path}` && !currentTab;
|
||||
}
|
||||
@@ -232,35 +228,12 @@ function ProjectNav({
|
||||
}
|
||||
|
||||
function getHref(item: NavItem): string {
|
||||
if (item.label === "Protocolli") return `${base}/report`;
|
||||
if (item.type === "route") return `${base}${item.path}`;
|
||||
return `${base}?tab=${item.tab}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex-1 px-2 py-3 space-y-1">
|
||||
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink href="/library" forceDocument={onNestedRoute}>
|
||||
<Library className="h-4 w-4" />
|
||||
Library
|
||||
</NavLink>
|
||||
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<p className="text-xs font-semibold text-foreground truncate">
|
||||
{project?.name ?? "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{isRunning && (
|
||||
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing
|
||||
</NavLink>
|
||||
)}
|
||||
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
|
||||
function renderItem(item: NavItem) {
|
||||
const active = isActive(item);
|
||||
const href = getHref(item);
|
||||
const forceDocument = item.type === "tab" && onNestedRoute;
|
||||
@@ -268,26 +241,53 @@ function ProjectNav({
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
|
||||
className="flex cursor-not-allowed items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] text-neutral-400"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
{item.adminOnly && (
|
||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
{item.adminOnly && (
|
||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex-1 space-y-1 px-2 py-3">
|
||||
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Progetti
|
||||
</NavLink>
|
||||
<NavLink href="/library" forceDocument={onNestedRoute}>
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Libreria
|
||||
</NavLink>
|
||||
|
||||
<p className="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
|
||||
{project?.name ?? "…"}
|
||||
</p>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{isRunning && (
|
||||
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Esame in corso
|
||||
</NavLink>
|
||||
)}
|
||||
{PRIMARY_NAV.map(renderItem)}
|
||||
</div>
|
||||
<div className="mt-3 space-y-0.5 border-t border-black/10 pt-2 dark:border-white/10">
|
||||
{SECONDARY_NAV.map(renderItem)}
|
||||
{isAdmin ? (
|
||||
<NavLink href={`${base}?tab=logs`} forceDocument={onNestedRoute}>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Log
|
||||
</NavLink>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
FindingStatus,
|
||||
DesignGraph,
|
||||
Collaborator,
|
||||
ProtocolCertificationSection,
|
||||
} from "@/lib/types";
|
||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||
import { isLayoutFinding } from "@/lib/layout-finding";
|
||||
@@ -33,6 +34,7 @@ interface FindingsListProps {
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
protocolSection?: ProtocolCertificationSection | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||
@@ -54,6 +56,7 @@ export function FindingsList({
|
||||
reportedFindingIds,
|
||||
reviews,
|
||||
onReviewSaved,
|
||||
protocolSection,
|
||||
}: FindingsListProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -161,8 +164,11 @@ export function FindingsList({
|
||||
reportedFindingIds,
|
||||
reviews,
|
||||
onReviewSaved,
|
||||
protocolSection,
|
||||
};
|
||||
|
||||
const showTree = filtered.length > 0 || (protocolSection?.recognized_instances?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ReportFilters
|
||||
@@ -180,7 +186,7 @@ export function FindingsList({
|
||||
onDomainChange={(v) => updateParams({ domain: v })}
|
||||
designators={designators}
|
||||
/>
|
||||
{filtered.length > 0 ? (
|
||||
{showTree ? (
|
||||
<FindingsTree findings={filtered} {...treeProps} />
|
||||
) : reviewedFindings.length === 0 && findings.length > 0 ? (
|
||||
<FindingsTreeEmpty />
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { FindingCard } from "./finding-card";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import { ProtocolInstanceBody } from "./protocol-section";
|
||||
import type {
|
||||
Finding,
|
||||
FindingComment,
|
||||
@@ -12,10 +10,21 @@ import type {
|
||||
Collaborator,
|
||||
Component,
|
||||
DesignGraph,
|
||||
FindingStatus,
|
||||
ProtocolCertificationSection,
|
||||
} from "@/lib/types";
|
||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
||||
import { groupByWorstStatus, worstStatus } from "@/lib/findings-forest";
|
||||
import {
|
||||
STATUS_TINT,
|
||||
forestHasCertifications,
|
||||
groupByWorstStatus,
|
||||
isProtocolFinding,
|
||||
protocolInstancesFromSection,
|
||||
protocolInstancesForStatus,
|
||||
worstStatus,
|
||||
type DomainGroup,
|
||||
type ProtocolTreeInstance,
|
||||
} from "@/lib/findings-forest";
|
||||
|
||||
interface FindingsTreeProps {
|
||||
findings: Finding[];
|
||||
@@ -35,6 +44,9 @@ interface FindingsTreeProps {
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
protocolSection?: ProtocolCertificationSection | null;
|
||||
selectedKey?: string | null;
|
||||
onSelect?: (key: string) => void;
|
||||
}
|
||||
|
||||
function pinNetLabel(f: Finding): string {
|
||||
@@ -46,73 +58,126 @@ function pinNetLabel(f: Finding): string {
|
||||
return f.rule_id || "Finding";
|
||||
}
|
||||
|
||||
function DisclosureMark({ open }: { open: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[2px]",
|
||||
"border border-black/20 text-[9px] leading-none text-neutral-600",
|
||||
"dark:border-white/25 dark:text-neutral-300",
|
||||
)}
|
||||
>
|
||||
{open ? "−" : "+"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeBranch({
|
||||
label,
|
||||
extra,
|
||||
findings,
|
||||
children,
|
||||
tint,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
label: string;
|
||||
extra?: string;
|
||||
findings: Finding[];
|
||||
findings: { status: FindingStatus }[];
|
||||
children: ReactNode;
|
||||
tint?: string;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const worst = worstStatus(findings);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger
|
||||
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
|
||||
>
|
||||
<ChevronRight
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
"flex w-full items-center gap-2 px-2 py-[5px] text-left text-[13px] transition-colors duration-150 ease-out",
|
||||
"hover:bg-black/[0.04] dark:hover:bg-white/[0.06]",
|
||||
tint,
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm truncate">{label}</span>
|
||||
>
|
||||
<DisclosureMark open={open} />
|
||||
<span className="min-w-0 truncate font-medium tracking-tight">{label}</span>
|
||||
{extra ? (
|
||||
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<StatusBadge status={worst} />
|
||||
<span className="text-xs text-muted-foreground">{findings.length}</span>
|
||||
<span className="hidden min-w-0 truncate text-[11px] text-neutral-500 sm:inline">
|
||||
{extra}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
|
||||
{findings.length}
|
||||
</span>
|
||||
<span className="sr-only">{worst}</span>
|
||||
</button>
|
||||
{open ? <div className="ml-[13px] border-l border-black/10 pl-2 dark:border-white/10">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTree(props: FindingsTreeProps) {
|
||||
const sorted = sortFindings(props.findings);
|
||||
const af = sorted.filter(isAfAiFinding);
|
||||
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
|
||||
const schematic = sorted.filter(
|
||||
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
|
||||
);
|
||||
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
||||
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
||||
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
||||
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
|
||||
const protocolFindings = sorted.filter(isProtocolFinding);
|
||||
const rest = sorted.filter((f) => !isProtocolFinding(f));
|
||||
const folders = groupByWorstStatus(rest.length ? rest : sorted.filter((f) => !isProtocolFinding(f)));
|
||||
const instances = protocolInstancesFromSection(props.protocolSection);
|
||||
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||
|
||||
const showRoots = roots.length > 1;
|
||||
const merged = order
|
||||
.map((status) => {
|
||||
const folder = folders.find((g) => g.status === status);
|
||||
const cert = forestHasCertifications(instances, protocolFindings, status);
|
||||
if (!folder && !cert) return null;
|
||||
return { status, folder, cert };
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
status: FindingStatus;
|
||||
folder: ReturnType<typeof groupByWorstStatus>[number] | undefined;
|
||||
cert: boolean;
|
||||
}[];
|
||||
|
||||
if (merged.length === 0 && instances.length === 0) {
|
||||
return <FindingsTreeEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
|
||||
{roots.map((root) => {
|
||||
const body = (
|
||||
<DesignatorForest
|
||||
<nav
|
||||
aria-label="Findings tree"
|
||||
className="overflow-hidden rounded-[11px] border border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.04]"
|
||||
>
|
||||
{merged.map(({ status, folder, cert }, index) => {
|
||||
const label =
|
||||
status === "ERROR" ? "Error" : status === "WARNING" ? "Warning" : "Info";
|
||||
const findings = [
|
||||
...(folder?.findings ?? []),
|
||||
...protocolFindings.filter((f) => f.status === status),
|
||||
];
|
||||
const protocolAtStatus = protocolFindings.filter((f) => f.status === status);
|
||||
const inst = protocolInstancesForStatus(instances, status);
|
||||
const count = Math.max(findings.length, inst.length);
|
||||
return (
|
||||
<TreeBranch
|
||||
key={status}
|
||||
label={label}
|
||||
findings={count ? findings : [{ status }]}
|
||||
tint={STATUS_TINT[status]}
|
||||
defaultOpen={index === 0}
|
||||
>
|
||||
{(folder?.domains ?? []).map((domain) => (
|
||||
<DomainBranch key={domain.id} domain={domain} {...props} />
|
||||
))}
|
||||
{cert ? (
|
||||
<CertificationsBranch
|
||||
{...props}
|
||||
findings={root.items}
|
||||
status={status}
|
||||
instances={inst}
|
||||
findings={protocolAtStatus}
|
||||
/>
|
||||
);
|
||||
if (!showRoots) return <div key={root.id}>{body}</div>;
|
||||
return (
|
||||
<TreeBranch key={root.id} label={root.label} findings={root.items}>
|
||||
{body}
|
||||
) : null}
|
||||
</TreeBranch>
|
||||
);
|
||||
})}
|
||||
@@ -120,25 +185,18 @@ export function FindingsTree(props: FindingsTreeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function DesignatorForest({
|
||||
findings,
|
||||
graph,
|
||||
...cardProps
|
||||
}: FindingsTreeProps) {
|
||||
const folders = groupByWorstStatus(findings);
|
||||
|
||||
function DomainBranch({
|
||||
domain,
|
||||
...props
|
||||
}: { domain: DomainGroup } & FindingsTreeProps) {
|
||||
return (
|
||||
<div>
|
||||
{folders.map((folder) => (
|
||||
<TreeBranch
|
||||
key={folder.status}
|
||||
label={folder.label}
|
||||
findings={folder.findings}
|
||||
>
|
||||
{folder.designators.map((row) => {
|
||||
<TreeBranch label={domain.label} findings={domain.findings}>
|
||||
{domain.rules.map((rule) => (
|
||||
<TreeBranch key={rule.ruleId} label={rule.ruleId} findings={rule.findings}>
|
||||
{rule.designators.map((row) => {
|
||||
const d = row.designator;
|
||||
const group = row.findings;
|
||||
const component: Component | undefined = graph.components[d];
|
||||
const component: Component | undefined = props.graph.components[d];
|
||||
const extra = [
|
||||
component?.mpn,
|
||||
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
|
||||
@@ -157,31 +215,31 @@ function DesignatorForest({
|
||||
<TreeBranch key={d} label={d} extra={extra} findings={group}>
|
||||
{leaves.map(([label, items]) => (
|
||||
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-2 py-1.5">
|
||||
{sortFindings(items).map((item) => {
|
||||
const key = cardProps.findingKeys.get(item);
|
||||
const key = props.findingKeys.get(item);
|
||||
return (
|
||||
<FindingCard
|
||||
key={key}
|
||||
finding={item}
|
||||
onViewReference={cardProps.onViewReference}
|
||||
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
|
||||
onViewReference={props.onViewReference}
|
||||
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
|
||||
onCheckedChange={
|
||||
key && cardProps.onToggleReviewed
|
||||
? () => cardProps.onToggleReviewed!(key)
|
||||
key && props.onToggleReviewed
|
||||
? () => props.onToggleReviewed!(key)
|
||||
: undefined
|
||||
}
|
||||
comments={item.finding_id ? cardProps.comments?.[item.finding_id] : undefined}
|
||||
projectId={cardProps.projectId}
|
||||
collaborators={cardProps.collaborators}
|
||||
currentUserId={cardProps.currentUserId}
|
||||
currentUserName={cardProps.currentUserName}
|
||||
onCommentAdded={cardProps.onCommentAdded}
|
||||
onCommentDeleted={cardProps.onCommentDeleted}
|
||||
onReportFinding={cardProps.onReportFinding}
|
||||
isReported={!!(item.finding_id && cardProps.reportedFindingIds?.has(item.finding_id))}
|
||||
review={item.finding_id ? cardProps.reviews?.[item.finding_id] : undefined}
|
||||
onReviewSaved={cardProps.onReviewSaved}
|
||||
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
|
||||
projectId={props.projectId}
|
||||
collaborators={props.collaborators}
|
||||
currentUserId={props.currentUserId}
|
||||
currentUserName={props.currentUserName}
|
||||
onCommentAdded={props.onCommentAdded}
|
||||
onCommentDeleted={props.onCommentDeleted}
|
||||
onReportFinding={props.onReportFinding}
|
||||
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
|
||||
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
|
||||
onReviewSaved={props.onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -193,14 +251,88 @@ function DesignatorForest({
|
||||
})}
|
||||
</TreeBranch>
|
||||
))}
|
||||
</TreeBranch>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificationsBranch({
|
||||
status,
|
||||
instances,
|
||||
findings,
|
||||
...props
|
||||
}: {
|
||||
status: FindingStatus;
|
||||
instances: ProtocolTreeInstance[];
|
||||
findings: Finding[];
|
||||
} & FindingsTreeProps) {
|
||||
const all = [...findings, ...instances.map(() => ({ status }))];
|
||||
return (
|
||||
<TreeBranch label="Certificazioni" findings={all.length ? all : [{ status }]} defaultOpen>
|
||||
<TreeBranch
|
||||
label="Protocolli"
|
||||
findings={all.length ? all : [{ status }]}
|
||||
defaultOpen
|
||||
>
|
||||
{instances.map((inst) => (
|
||||
<TreeBranch
|
||||
key={inst.instanceId}
|
||||
label={`${inst.logicalId || "protocol"} → ${inst.physicalId || "—"}`}
|
||||
extra={inst.recognition}
|
||||
findings={[{ status: inst.status }]}
|
||||
>
|
||||
<ProtocolInstanceBody instance={inst} />
|
||||
</TreeBranch>
|
||||
))}
|
||||
{findings.length > 0 ? (
|
||||
<TreeBranch label="Findings" findings={findings}>
|
||||
<div className="space-y-2 py-1.5">
|
||||
{sortFindings(findings).map((item) => {
|
||||
const key = props.findingKeys.get(item);
|
||||
return (
|
||||
<FindingCard
|
||||
key={key}
|
||||
finding={item}
|
||||
onViewReference={props.onViewReference}
|
||||
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
|
||||
onCheckedChange={
|
||||
key && props.onToggleReviewed
|
||||
? () => props.onToggleReviewed!(key)
|
||||
: undefined
|
||||
}
|
||||
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
|
||||
projectId={props.projectId}
|
||||
collaborators={props.collaborators}
|
||||
currentUserId={props.currentUserId}
|
||||
currentUserName={props.currentUserName}
|
||||
onCommentAdded={props.onCommentAdded}
|
||||
onCommentDeleted={props.onCommentDeleted}
|
||||
onReportFinding={props.onReportFinding}
|
||||
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
|
||||
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
|
||||
onReviewSaved={props.onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TreeBranch>
|
||||
) : null}
|
||||
{instances.length === 0 && findings.length === 0 ? (
|
||||
<p className="px-2 py-2 text-[13px] text-neutral-500">
|
||||
{props.protocolSection?.message || "Nessun protocollo riconosciuto."}
|
||||
</p>
|
||||
) : null}
|
||||
</TreeBranch>
|
||||
</TreeBranch>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTreeEmpty() {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No findings match your filters.
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
|
||||
<p className="text-[15px] font-medium tracking-tight">Nessun finding visibile</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
I filtri nascondono l’elenco. Togli un filtro, oppure apri i findings già rivisti sotto.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,176 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import type { ProtocolCertificationSection } from "@/lib/types";
|
||||
import { StatusBadge } from "@/components/report/status-badge";
|
||||
import {
|
||||
formatLimit,
|
||||
formatMargin,
|
||||
formatMeasured,
|
||||
formatProtocolChain,
|
||||
protocolResultToFindingStatus,
|
||||
sortProtocolChecks,
|
||||
type ProtocolReportCheck,
|
||||
} from "@/lib/protocol-report";
|
||||
import {
|
||||
protocolInstancesFromSection,
|
||||
type ProtocolTreeInstance,
|
||||
} from "@/lib/findings-forest";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
|
||||
const raw = row.report_checks;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return sortProtocolChecks(raw as ProtocolReportCheck[]);
|
||||
}
|
||||
const fallback: ProtocolReportCheck[] = [];
|
||||
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
|
||||
const list = row[key];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const c of list) {
|
||||
const rec = c as Record<string, unknown>;
|
||||
fallback.push({
|
||||
check: String(rec.check ?? ""),
|
||||
level: String(rec.level ?? ""),
|
||||
result: String(rec.result ?? ""),
|
||||
notes: String(rec.notes ?? ""),
|
||||
skip_visible:
|
||||
String(rec.result ?? "") !== "PASS" &&
|
||||
String(rec.result ?? "") !== "FAIL",
|
||||
});
|
||||
}
|
||||
}
|
||||
return sortProtocolChecks(fallback);
|
||||
}
|
||||
|
||||
export function ProtocolSection({
|
||||
section,
|
||||
}: {
|
||||
section?: ProtocolCertificationSection | null;
|
||||
}) {
|
||||
const instances = section?.recognized_instances ?? [];
|
||||
const message = section?.message || "nessun protocollo riconosciuto";
|
||||
const maxLevel = section?.max_level_reached || "—";
|
||||
export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInstance }) {
|
||||
const checks = sortProtocolChecks(instance.checks);
|
||||
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
|
||||
const logical = instance.logicalId;
|
||||
const physical = instance.physicalId;
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">Protocolli</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Livello massimo: <span className="font-mono text-foreground">{maxLevel}</span>
|
||||
{" "}
|
||||
(L0–L3; L3 skip unless channel FACT exists — no OpenEMS)
|
||||
<div className="space-y-2 py-2 pr-2">
|
||||
<p className="text-[11px] tabular-nums text-neutral-500">
|
||||
{logical} → {physical}
|
||||
{instance.recognition ? ` · ${instance.recognition}` : ""}
|
||||
{instance.worst ? ` · ${instance.worst}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Logical protocol vs physical interface. L0 structural, L1 geometric,
|
||||
L2 electrical (Z only from datasheet/stackup/fab or a cited pack).
|
||||
L3 channel (timing budget / IL / RL / crosstalk) only from existing FACT.
|
||||
L0/L1 are not electrical. Length is not delay. FAIL is listed first —
|
||||
it is not filed under Warning.
|
||||
</p>
|
||||
{instances.length === 0 ? (
|
||||
<p className="text-sm">{message}</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{instances.map((row, i) => {
|
||||
const rec = row as Record<string, unknown>;
|
||||
const phys = String(rec.physical_interface_id ?? "");
|
||||
const log = String(rec.logical_protocol_id ?? "");
|
||||
const status = String(rec.recognition_status ?? "");
|
||||
const worst = String(rec.worst_result ?? "");
|
||||
const checks = asChecks(rec);
|
||||
const fail = Number(rec.fail_count ?? 0) > 0 || worst === "FAIL";
|
||||
return (
|
||||
<li
|
||||
key={String(rec.instance_id ?? i)}
|
||||
className={cn(
|
||||
"rounded-md border p-3 space-y-2",
|
||||
fail
|
||||
? "border-rose-500/50 bg-rose-500/5"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-mono text-xs">
|
||||
{log} → {phys}
|
||||
{status ? ` · ${status}` : ""}
|
||||
</p>
|
||||
{worst ? (
|
||||
<StatusBadge status={protocolResultToFindingStatus(worst)} />
|
||||
) : null}
|
||||
{fail ? (
|
||||
<span className="text-xs font-medium text-rose-600 dark:text-rose-400">
|
||||
FAIL visible — not under Warning
|
||||
</span>
|
||||
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL in testa — non sotto Warning.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-[11px] tabular-nums">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
<th className="pr-2 py-1 font-medium">Lv</th>
|
||||
<th className="pr-2 py-1 font-medium">Check</th>
|
||||
<th className="pr-2 py-1 font-medium">Result</th>
|
||||
<th className="pr-2 py-1 font-medium">Measured</th>
|
||||
<th className="pr-2 py-1 font-medium">Limit</th>
|
||||
<th className="pr-2 py-1 font-medium">Margin</th>
|
||||
<th className="pr-2 py-1 font-medium">Source</th>
|
||||
<tr className="text-left text-neutral-500">
|
||||
<th className="py-1 pr-2 font-medium">Lv</th>
|
||||
<th className="py-1 pr-2 font-medium">Check</th>
|
||||
<th className="py-1 pr-2 font-medium">Result</th>
|
||||
<th className="py-1 pr-2 font-medium">Measured</th>
|
||||
<th className="py-1 pr-2 font-medium">Limit</th>
|
||||
<th className="py-1 pr-2 font-medium">Margin</th>
|
||||
<th className="py-1 pr-2 font-medium">Source</th>
|
||||
<th className="py-1 font-medium">Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c, j) => {
|
||||
const isFail = c.result === "FAIL";
|
||||
const skip = Boolean(c.skip_visible);
|
||||
return (
|
||||
<tr
|
||||
{checks.map((c, j) => (
|
||||
<ProtocolCheckRow
|
||||
key={`${c.level}-${c.check}-${j}`}
|
||||
className={cn(
|
||||
isFail && "text-rose-700 dark:text-rose-400 font-medium",
|
||||
skip && !isFail && "text-amber-800 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
<td className="pr-2 py-1 font-mono">{c.level}</td>
|
||||
<td className="pr-2 py-1">{c.check}</td>
|
||||
<td className="pr-2 py-1">
|
||||
{c.result}
|
||||
{skip ? " · skip" : ""}
|
||||
</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatMeasured(c)}</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatLimit(c)}</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatMargin(c)}</td>
|
||||
<td className="pr-2 py-1">{c.source || "—"}</td>
|
||||
<td className="py-1">{c.method || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
check={c}
|
||||
logicalId={logical}
|
||||
physicalId={physical}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{String(rec.chain_example || "") ? (
|
||||
<p className="text-[11px] text-muted-foreground break-all">
|
||||
Catena: {String(rec.chain_example)}
|
||||
</p>
|
||||
{instance.chainExample ? (
|
||||
<p className="break-all text-[11px] text-neutral-500">Catena: {instance.chainExample}</p>
|
||||
) : null}
|
||||
{checks
|
||||
.filter((c) => c.result === "FAIL" || c.skip_visible)
|
||||
.map((c, j) =>
|
||||
c.chain ? (
|
||||
<p
|
||||
key={`chain-${j}`}
|
||||
className="text-[11px] text-muted-foreground break-all"
|
||||
>
|
||||
<p key={`chain-${j}`} className="break-all text-[11px] text-neutral-500">
|
||||
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
|
||||
</p>
|
||||
) : null,
|
||||
)}
|
||||
{checks.some((c) => c.skip_visible && c.notes) ? (
|
||||
<ul className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
|
||||
{checks
|
||||
.filter((c) => c.skip_visible && c.notes)
|
||||
.map((c, j) => (
|
||||
<li key={`skip-${j}`}>{c.notes}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
|
||||
function ProtocolCheckRow({
|
||||
check: c,
|
||||
logicalId,
|
||||
physicalId,
|
||||
}: {
|
||||
check: ProtocolReportCheck;
|
||||
logicalId: string;
|
||||
physicalId: string;
|
||||
}) {
|
||||
const isFail = c.result === "FAIL";
|
||||
const unknown = c.result === "UNKNOWN";
|
||||
const measured = formatMeasured(c, logicalId, physicalId);
|
||||
const limit = formatLimit(c, logicalId, physicalId);
|
||||
const margin = formatMargin(c, logicalId, physicalId);
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-t border-black/[0.06] dark:border-white/10",
|
||||
isFail && "font-medium text-[rgb(180,40,35)]",
|
||||
unknown && !isFail && "text-neutral-600",
|
||||
)}
|
||||
>
|
||||
<td className="py-1 pr-2 font-mono">{c.level}</td>
|
||||
<td className="py-1 pr-2">{c.check}</td>
|
||||
<td className="py-1 pr-2">{c.result}{c.skip_visible && c.result !== "FAIL" ? " · skip" : ""}</td>
|
||||
<td className="py-1 pr-2 font-mono">{measured}</td>
|
||||
<td className="py-1 pr-2 font-mono">{limit}</td>
|
||||
<td className="py-1 pr-2 font-mono">{margin}</td>
|
||||
<td className="py-1 pr-2">{c.source || "—"}</td>
|
||||
<td className="py-1">{c.method || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** Standalone block — used only if the tree is empty of findings. */
|
||||
export function ProtocolSection({
|
||||
section,
|
||||
}: {
|
||||
section?: ProtocolCertificationSection | null;
|
||||
}) {
|
||||
const instances = protocolInstancesFromSection(section);
|
||||
const message = section?.message || "Nessun protocollo riconosciuto.";
|
||||
const maxLevel = section?.max_level_reached || "—";
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Livello massimo {maxLevel} (L0–L3). HDMI 1.4 TMDS resta UNKNOWN — niente Zdiff inventato.
|
||||
</p>
|
||||
{instances.length === 0 ? (
|
||||
<p className="text-[13px] text-neutral-600">{message}</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{instances.map((inst) => (
|
||||
<li key={inst.instanceId}>
|
||||
<ProtocolInstanceBody instance={inst} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ReportSummaryProps {
|
||||
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
|
||||
totalCostUsd?: number | null;
|
||||
}
|
||||
|
||||
const CELLS = [
|
||||
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
|
||||
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
|
||||
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
|
||||
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
|
||||
];
|
||||
|
||||
export function ReportSummary({
|
||||
summary,
|
||||
reviewedCount,
|
||||
creditsSpent,
|
||||
totalCostUsd,
|
||||
}: ReportSummaryProps) {
|
||||
const total = summary.total || 0;
|
||||
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
|
||||
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
|
||||
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
|
||||
const grid =
|
||||
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
|
||||
const errors = summary.ERROR ?? 0;
|
||||
const warnings = summary.WARNING ?? 0;
|
||||
const infos = summary.INFO ?? 0;
|
||||
const total = summary.total || errors + warnings + infos;
|
||||
const outcome =
|
||||
errors > 0 ? "Error" : warnings > 0 ? "Attenzione" : total > 0 ? "Completato" : "Senza findings";
|
||||
const bits = [
|
||||
`${errors} Error`,
|
||||
`${warnings} Warning`,
|
||||
`${infos} Info`,
|
||||
`${reviewedCount} rivisti`,
|
||||
];
|
||||
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
|
||||
bits.push(`$${totalCostUsd.toFixed(2)}`);
|
||||
}
|
||||
if (typeof creditsSpent === "number" && creditsSpent > 0) {
|
||||
bits.push(`${creditsSpent.toFixed(2)} crediti`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={cn("grid gap-4", grid)}>
|
||||
{CELLS.map(({ key, label, color }) => (
|
||||
<Card key={key}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
|
||||
{summary[key] ?? 0}
|
||||
<p className={cn("text-[13px] tabular-nums text-neutral-500")}>
|
||||
<span className="font-medium text-neutral-800 dark:text-neutral-200">{outcome}</span>
|
||||
{" · "}
|
||||
{bits.join(" · ")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">Checked</p>
|
||||
<p className="text-3xl font-semibold font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||
{reviewedCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{showCost && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">API cost</p>
|
||||
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
|
||||
${totalCostUsd!.toFixed(2)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{showCredits && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">Credits</p>
|
||||
<p className="text-3xl font-semibold font-mono tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{creditsSpent!.toFixed(2)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
|
||||
{CELLS.filter((s) => s.key !== "total" && (summary[s.key] ?? 0) > 0).map(
|
||||
({ key, barColor }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn("h-full", barColor)}
|
||||
style={{ width: `${((summary[key] ?? 0) / total) * 100}%` }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { FindingStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Footnote-weight status. Light fill only (~7%), never a saturated badge. */
|
||||
const TONE: Record<FindingStatus, string> = {
|
||||
ERROR: "bg-rose-500/10 text-rose-600 border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-400",
|
||||
WARNING: "bg-amber-500/10 text-amber-600 border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-400",
|
||||
INFO: "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-400",
|
||||
ERROR: "bg-[rgba(255,59,48,0.07)] text-[rgb(160,30,28)]",
|
||||
WARNING: "bg-[rgba(255,214,10,0.08)] text-[rgb(140,100,20)]",
|
||||
INFO: "bg-[rgba(142,142,147,0.08)] text-[rgb(70,70,78)]",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: FindingStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn("text-xs font-medium", TONE[status])}>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-[6px] px-1.5 py-px text-[11px] font-medium tracking-tight",
|
||||
TONE[status],
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { groupByWorstStatus, worstStatus } from "./findings-forest.ts";
|
||||
import { isPcbExamFinding } from "./layout-finding.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
import { groupByWorstStatus, groupDomains, isProtocolFinding, worstStatus } from "./findings-forest";
|
||||
import { isPcbExamFinding } from "./layout-finding";
|
||||
import type { Finding } from "./types";
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||
return {
|
||||
@@ -50,6 +50,28 @@ test("Error folder lists J2 connectors, not only U*", () => {
|
||||
assert.equal(groups[1].label, "Warning");
|
||||
});
|
||||
|
||||
test("Error folder lists domains and rules after status, protocol stays out of those domains", () => {
|
||||
const groups = groupByWorstStatus([
|
||||
f({ designator: "U18", status: "ERROR", rule_id: "PE-PLC-002" }),
|
||||
f({ designator: "U18", status: "WARNING", rule_id: "PE-PLC-001" }),
|
||||
f({
|
||||
designator: "J1",
|
||||
status: "ERROR",
|
||||
rule_id: "PE-PRT-L0-001",
|
||||
source: "protocol_l0",
|
||||
}),
|
||||
]);
|
||||
assert.equal(groups[0].label, "Error");
|
||||
assert.deepEqual(
|
||||
groups[0].domains.map((d) => d.id),
|
||||
["PLC"],
|
||||
);
|
||||
assert.equal(groups[0].domains[0].rules.map((r) => r.ruleId).sort().join(","), "PE-PLC-001,PE-PLC-002");
|
||||
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
|
||||
assert.ok(isProtocolFinding({ rule_id: "PE-PRT-L0-001", source: "protocol_l0" }));
|
||||
assert.equal(groupDomains(groups[0].findings.filter((x) => !isProtocolFinding(x))).length, 1);
|
||||
});
|
||||
|
||||
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
|
||||
assert.equal(
|
||||
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/** Group report findings for the tree: status folders, then every ref (J2 included). */
|
||||
/** Group report findings for the tree: status folders, then domains/rules, then Certificazioni. */
|
||||
|
||||
import type { Finding, FindingStatus } from "./types";
|
||||
|
||||
const RANK: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
||||
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
|
||||
import {
|
||||
instanceWorstResult,
|
||||
protocolResultToFindingStatus,
|
||||
type ProtocolReportCheck,
|
||||
} from "./protocol-report";
|
||||
|
||||
export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
||||
ERROR: "Error",
|
||||
@@ -10,6 +13,46 @@ export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
||||
INFO: "Info",
|
||||
};
|
||||
|
||||
export const STATUS_TINT: Record<FindingStatus, string> = {
|
||||
ERROR: "bg-[rgba(255,59,48,0.07)]",
|
||||
WARNING: "bg-[rgba(255,214,10,0.08)]",
|
||||
INFO: "bg-[rgba(142,142,147,0.08)]",
|
||||
};
|
||||
|
||||
const DOMAIN_LABEL: Record<string, string> = {
|
||||
PLC: "Placement",
|
||||
LAY: "Layout",
|
||||
SI: "Signal integrity",
|
||||
BOM: "BOM",
|
||||
PWR: "Power",
|
||||
THM: "Thermal",
|
||||
VIA: "Vias",
|
||||
KEL: "Kelvin",
|
||||
STCH: "Stitching",
|
||||
DDR: "DDR",
|
||||
CPU: "CPU",
|
||||
FPGA: "FPGA",
|
||||
DRT: "Derating",
|
||||
HIER: "Hierarchy",
|
||||
TIM: "Timing",
|
||||
PI: "Power integrity",
|
||||
ESD: "ESD",
|
||||
RET: "Return path",
|
||||
SPOF: "SPOF",
|
||||
EMI: "EMI",
|
||||
STK: "Stackup",
|
||||
ANT: "Antenna",
|
||||
PDN: "PDN",
|
||||
PD: "USB-PD",
|
||||
AF: "AF Board + AI",
|
||||
PRT: "Protocolli",
|
||||
MUX: "Pin mux",
|
||||
NC: "NC pins",
|
||||
DEC: "Decoupling",
|
||||
SCH: "Schematic",
|
||||
PCB: "PCB exam",
|
||||
};
|
||||
|
||||
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
|
||||
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
|
||||
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
|
||||
@@ -21,11 +64,38 @@ export type DesignatorGroup = {
|
||||
findings: Finding[];
|
||||
};
|
||||
|
||||
export type RuleGroup = {
|
||||
ruleId: string;
|
||||
findings: Finding[];
|
||||
designators: DesignatorGroup[];
|
||||
};
|
||||
|
||||
export type DomainGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
findings: Finding[];
|
||||
rules: RuleGroup[];
|
||||
};
|
||||
|
||||
export type StatusGroup = {
|
||||
status: FindingStatus;
|
||||
label: string;
|
||||
findings: Finding[];
|
||||
designators: DesignatorGroup[];
|
||||
domains: DomainGroup[];
|
||||
};
|
||||
|
||||
export type ProtocolTreeInstance = {
|
||||
instanceId: string;
|
||||
logicalId: string;
|
||||
physicalId: string;
|
||||
recognition: string;
|
||||
worst: string;
|
||||
status: FindingStatus;
|
||||
checks: ProtocolReportCheck[];
|
||||
notes: string;
|
||||
chainExample: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Sidebar rows: ICs, connectors (J*), and any other ref. Never U-only. */
|
||||
@@ -42,9 +112,68 @@ export function groupByDesignator(findings: Finding[]): DesignatorGroup[] {
|
||||
.map(([designator, items]) => ({ designator, findings: items }));
|
||||
}
|
||||
|
||||
export function isProtocolFinding(f: {
|
||||
source?: string | null;
|
||||
rule_id?: string | null;
|
||||
}): boolean {
|
||||
const src = f.source || "";
|
||||
const rid = f.rule_id || "";
|
||||
return src.startsWith("protocol_") || rid.startsWith("PE-PRT");
|
||||
}
|
||||
|
||||
export function ruleDomain(f: Finding): { id: string; label: string } {
|
||||
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocolli" };
|
||||
const rid = f.rule_id || "";
|
||||
const m = rid.match(/^PE-([A-Z]+)/);
|
||||
if (m) {
|
||||
const id = m[1];
|
||||
return { id, label: DOMAIN_LABEL[id] || id };
|
||||
}
|
||||
if ((f.finding_id || "").startsWith("PCB-") || (f.source || "").includes("pcb")) {
|
||||
return { id: "PCB", label: DOMAIN_LABEL.PCB };
|
||||
}
|
||||
return { id: "SCH", label: DOMAIN_LABEL.SCH };
|
||||
}
|
||||
|
||||
export function groupRules(findings: Finding[]): RuleGroup[] {
|
||||
const byRule = new Map<string, Finding[]>();
|
||||
for (const f of findings) {
|
||||
const id = (f.rule_id || "").trim() || "(no rule)";
|
||||
const list = byRule.get(id) ?? [];
|
||||
list.push(f);
|
||||
byRule.set(id, list);
|
||||
}
|
||||
return [...byRule.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([ruleId, items]) => ({
|
||||
ruleId,
|
||||
findings: items,
|
||||
designators: groupByDesignator(items),
|
||||
}));
|
||||
}
|
||||
|
||||
export function groupDomains(findings: Finding[]): DomainGroup[] {
|
||||
const byId = new Map<string, Finding[]>();
|
||||
for (const f of findings) {
|
||||
const { id } = ruleDomain(f);
|
||||
const list = byId.get(id) ?? [];
|
||||
list.push(f);
|
||||
byId.set(id, list);
|
||||
}
|
||||
return [...byId.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([id, items]) => ({
|
||||
id,
|
||||
label: DOMAIN_LABEL[id] || id,
|
||||
findings: items,
|
||||
rules: groupRules(items),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
|
||||
* U18 with PE-PLC-002 ERROR + five WARNING → Error, badge ERROR, count 6.
|
||||
* Protocol findings (PE-PRT) are excluded from domain folders — they sit under Certificazioni.
|
||||
*/
|
||||
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
|
||||
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
|
||||
@@ -60,11 +189,80 @@ export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
|
||||
.filter((status) => buckets[status].length > 0)
|
||||
.map((status) => {
|
||||
const designators = buckets[status];
|
||||
const folderFindings = designators.flatMap((d) => d.findings);
|
||||
const nonProtocol = folderFindings.filter((f) => !isProtocolFinding(f));
|
||||
return {
|
||||
status,
|
||||
label: STATUS_FOLDER[status],
|
||||
findings: designators.flatMap((d) => d.findings),
|
||||
findings: folderFindings,
|
||||
designators,
|
||||
domains: groupDomains(nonProtocol),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
|
||||
const raw = row.report_checks;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return raw as ProtocolReportCheck[];
|
||||
}
|
||||
const fallback: ProtocolReportCheck[] = [];
|
||||
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
|
||||
const list = row[key];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const c of list) {
|
||||
const rec = c as Record<string, unknown>;
|
||||
fallback.push({
|
||||
check: String(rec.check ?? ""),
|
||||
level: String(rec.level ?? ""),
|
||||
result: String(rec.result ?? ""),
|
||||
notes: String(rec.notes ?? ""),
|
||||
skip_visible:
|
||||
String(rec.result ?? "") !== "PASS" &&
|
||||
String(rec.result ?? "") !== "FAIL",
|
||||
});
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function protocolInstancesFromSection(
|
||||
section?: ProtocolCertificationSection | null,
|
||||
): ProtocolTreeInstance[] {
|
||||
const instances = section?.recognized_instances ?? [];
|
||||
return instances.map((row, i) => {
|
||||
const rec = row as Record<string, unknown>;
|
||||
const worst = String(rec.worst_result ?? "");
|
||||
const checks = asChecks(rec);
|
||||
const results = checks.map((c) => c.result).filter(Boolean);
|
||||
const worstResolved = worst || instanceWorstResult(results);
|
||||
return {
|
||||
instanceId: String(rec.instance_id ?? i),
|
||||
logicalId: String(rec.logical_protocol_id ?? ""),
|
||||
physicalId: String(rec.physical_interface_id ?? ""),
|
||||
recognition: String(rec.recognition_status ?? ""),
|
||||
worst: worstResolved,
|
||||
status: protocolResultToFindingStatus(worstResolved),
|
||||
checks,
|
||||
notes: String(rec.notes ?? rec.message ?? ""),
|
||||
chainExample: String(rec.chain_example ?? ""),
|
||||
raw: rec,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function protocolInstancesForStatus(
|
||||
instances: ProtocolTreeInstance[],
|
||||
status: FindingStatus,
|
||||
): ProtocolTreeInstance[] {
|
||||
return instances.filter((row) => row.status === status);
|
||||
}
|
||||
|
||||
export function forestHasCertifications(
|
||||
instances: ProtocolTreeInstance[],
|
||||
protocolFindings: Finding[],
|
||||
status: FindingStatus,
|
||||
): boolean {
|
||||
if (protocolInstancesForStatus(instances, status).length > 0) return true;
|
||||
return protocolFindings.some((f) => f.status === status);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
formatLimit,
|
||||
formatMeasured,
|
||||
formatProtocolChain,
|
||||
isHdmi14TmdsElectrical,
|
||||
instanceWorstResult,
|
||||
protocolResultRank,
|
||||
protocolResultToFindingStatus,
|
||||
sortProtocolChecks,
|
||||
} from "./protocol-report.ts";
|
||||
import { groupByWorstStatus } from "./findings-forest.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
} from "./protocol-report";
|
||||
import { groupByWorstStatus } from "./findings-forest";
|
||||
import type { Finding } from "./types";
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||
return {
|
||||
@@ -75,3 +78,37 @@ test("protocol FAIL finding stays in Error folder with WARNING skips on same ref
|
||||
assert.equal(groups[0].status, "ERROR");
|
||||
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
|
||||
});
|
||||
|
||||
test("HDMI 1.4 TMDS Zdiff displays UNKNOWN, never a guessed ohm number", () => {
|
||||
const row = {
|
||||
check: "differential_impedance",
|
||||
level: "L2",
|
||||
result: "UNKNOWN",
|
||||
measured: 100,
|
||||
limit: 100,
|
||||
margin: 0,
|
||||
unit: "ohm",
|
||||
chain: {
|
||||
logical_protocol_id: "hdmi-1.4",
|
||||
physical_interface_id: "hdmi-1.4-tmds-type-a",
|
||||
},
|
||||
};
|
||||
assert.equal(isHdmi14TmdsElectrical(row), true);
|
||||
assert.equal(formatLimit(row), "UNKNOWN");
|
||||
assert.equal(formatMeasured(row), "UNKNOWN");
|
||||
assert.ok(!formatLimit(row).includes("100"));
|
||||
assert.ok(!formatMeasured(row).includes("Ω"));
|
||||
});
|
||||
|
||||
test("HDMI 1.4 CEC is not treated as TMDS Zdiff", () => {
|
||||
const row = {
|
||||
check: "cec",
|
||||
level: "L2",
|
||||
result: "PASS",
|
||||
limit: 27000,
|
||||
unit: "ohm",
|
||||
chain: { logical_protocol_id: "hdmi-1.4" },
|
||||
};
|
||||
assert.equal(isHdmi14TmdsElectrical(row), false);
|
||||
assert.equal(formatLimit(row), "27000 ohm");
|
||||
});
|
||||
|
||||
@@ -94,19 +94,80 @@ export function formatProtocolChain(chain?: ProtocolChain | null): string {
|
||||
return keys.map((k) => chain[k] || "—").join(" → ");
|
||||
}
|
||||
|
||||
export function formatMeasured(row: ProtocolReportCheck): string {
|
||||
const HDMI14_TMDS_ELECTRICAL = new Set([
|
||||
"differential_impedance",
|
||||
"impedance_tolerance",
|
||||
"intra_pair_skew",
|
||||
"inter_lane_skew",
|
||||
"clock_data_relationship",
|
||||
"data_rate",
|
||||
"transition_rate",
|
||||
"channel_loss",
|
||||
"insertion_loss",
|
||||
"return_loss",
|
||||
"crosstalk",
|
||||
]);
|
||||
|
||||
export function isHdmi14Protocol(id?: string | null): boolean {
|
||||
const s = (id || "").toLowerCase();
|
||||
return s === "hdmi-1.4" || s.startsWith("hdmi-1.4-");
|
||||
}
|
||||
|
||||
/** HDMI 1.4 TMDS electrical is UNKNOWN until the Adopter spec is on file. CEC from HDMI 1.3 stays. */
|
||||
export function isHdmi14TmdsElectrical(
|
||||
row: Pick<ProtocolReportCheck, "check" | "chain">,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): boolean {
|
||||
const logical = logicalId || row.chain?.logical_protocol_id || "";
|
||||
const physical = physicalId || row.chain?.physical_interface_id || "";
|
||||
if (!isHdmi14Protocol(logical) && !isHdmi14Protocol(physical)) return false;
|
||||
const check = (row.check || "").toLowerCase();
|
||||
if (check === "cec" || check.startsWith("cec_")) return false;
|
||||
if (HDMI14_TMDS_ELECTRICAL.has(check)) return true;
|
||||
return check.includes("impedance") || check.includes("zdiff");
|
||||
}
|
||||
|
||||
export function formatUnknownNotGuessed(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): boolean {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return true;
|
||||
return row.result === "UNKNOWN";
|
||||
}
|
||||
|
||||
export function formatMeasured(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): string {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
|
||||
if (row.result === "UNKNOWN") return "UNKNOWN";
|
||||
if (row.measured == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.measured}${unit}`;
|
||||
}
|
||||
|
||||
export function formatLimit(row: ProtocolReportCheck): string {
|
||||
export function formatLimit(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): string {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
|
||||
if (row.result === "UNKNOWN") return "UNKNOWN";
|
||||
if (row.limit == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.limit}${unit}`;
|
||||
}
|
||||
|
||||
export function formatMargin(row: ProtocolReportCheck): string {
|
||||
export function formatMargin(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): string {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
|
||||
if (row.result === "UNKNOWN") return "UNKNOWN";
|
||||
if (row.margin == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.margin}${unit}`;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
||||
export const APP_VERSION = "2.66.0";
|
||||
export const APP_VERSION = "2.83.0";
|
||||
export const APP_VERSION_DATE = "2026-09-22";
|
||||
|
||||
Reference in New Issue
Block a user