Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e872cfbc3 | ||
|
|
3046c4aeee | ||
|
|
b32d581d27 | ||
|
|
d5fff7c6dd |
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
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] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
|
||||||
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
|
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
LayoutGrid,
|
|
||||||
List,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCredits } from "@/components/billing/credits-context";
|
import { useCredits } from "@/components/billing/credits-context";
|
||||||
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
|
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
|
||||||
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
|
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
|
||||||
import { ProjectCard } from "@/components/dashboard/project-card";
|
|
||||||
import { ProjectsTable } from "@/components/dashboard/projects-table";
|
import { ProjectsTable } from "@/components/dashboard/projects-table";
|
||||||
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@@ -23,13 +21,10 @@ import {
|
|||||||
fetchProjects,
|
fetchProjects,
|
||||||
reconcileCheckoutSession,
|
reconcileCheckoutSession,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type { CreditSnapshot, Project } from "@/lib/types";
|
import type { Project } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type LayoutKind = "cards" | "table";
|
|
||||||
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
|
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
|
||||||
|
|
||||||
const LAYOUT_KEY = "periscopex:dashboard:view";
|
|
||||||
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
|
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
@@ -62,28 +57,9 @@ function DashboardBody() {
|
|||||||
const [bannerVisible, setBannerVisible] = useState(false);
|
const [bannerVisible, setBannerVisible] = useState(false);
|
||||||
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
|
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
|
||||||
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
|
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
|
||||||
const [layout, setLayout] = useState<LayoutKind>("cards");
|
|
||||||
|
|
||||||
const ordered = useMemo(() => newestFirst(projects), [projects]);
|
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() {
|
function stripCheckoutQuery() {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.delete("topup");
|
url.searchParams.delete("topup");
|
||||||
@@ -208,7 +184,7 @@ function DashboardBody() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 />
|
<OnboardingSurvey />
|
||||||
{bannerVisible && topupPhase !== "dismissed" && (
|
{bannerVisible && topupPhase !== "dismissed" && (
|
||||||
<TopupBanner
|
<TopupBanner
|
||||||
@@ -217,116 +193,44 @@ function DashboardBody() {
|
|||||||
onDismiss={hideBanner}
|
onDismiss={hideBanner}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<PageHeader
|
||||||
<div>
|
title="Progetti"
|
||||||
<h1 className="text-lg font-semibold">Projects</h1>
|
meta={credits ? `${credits.balance.toFixed(2)} crediti` : "Un progetto, una finestra."}
|
||||||
<CreditsLine credits={credits} />
|
actions={
|
||||||
</div>
|
<CreateProjectDialog
|
||||||
<CreateProjectDialog
|
rerunProject={rerunProject}
|
||||||
rerunProject={rerunProject}
|
onRerunDone={() => setRerunProject(null)}
|
||||||
onRerunDone={() => setRerunProject(null)}
|
cloneAsNewProject={cloneAsNewProject}
|
||||||
cloneAsNewProject={cloneAsNewProject}
|
onCloneAsNewDone={() => setCloneAsNewProject(null)}
|
||||||
onCloneAsNewDone={() => setCloneAsNewProject(null)}
|
onCreateProject={upsertProject}
|
||||||
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 ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-36 rounded-lg" />
|
<Skeleton key={i} className="h-12 rounded-[11px]" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-12">
|
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
|
||||||
No projects yet. Create one to get started.
|
<p className="text-[15px] font-medium tracking-tight">Nessun progetto</p>
|
||||||
</p>
|
<p className="mt-1 text-[13px] text-neutral-500">
|
||||||
) : layout === "table" ? (
|
Crea un progetto e carica netlist, BOM e PCB. L’esame parte da lì.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ProjectsTable
|
<ProjectsTable
|
||||||
projects={ordered}
|
projects={ordered}
|
||||||
onDeleted={dropProject}
|
onDeleted={dropProject}
|
||||||
onRerun={setRerunProject}
|
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>
|
</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({
|
function TopupBanner({
|
||||||
phase,
|
phase,
|
||||||
detail,
|
detail,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function AppShellLayout({
|
|||||||
<AuthGate>
|
<AuthGate>
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<Sidebar />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
|
|||||||
import { ReportSummary } from "@/components/report/report-summary";
|
import { ReportSummary } from "@/components/report/report-summary";
|
||||||
import { FindingsList } from "@/components/report/findings-list";
|
import { FindingsList } from "@/components/report/findings-list";
|
||||||
import { FindingFocusView } from "@/components/report/finding-focus-view";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Toast, useToast } from "@/components/ui/toast";
|
import { Toast, useToast } from "@/components/ui/toast";
|
||||||
@@ -240,18 +240,14 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
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={cn("space-y-5", focus && "hidden")}>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<PageHeader
|
||||||
<div>
|
title={projectName || "Esame"}
|
||||||
<h1 className="text-lg font-semibold">Validation Report</h1>
|
meta={`${report.findings.length} findings · ${new Date(report.timestamp).toLocaleDateString()}`}
|
||||||
<p className="text-sm text-muted-foreground">
|
actions={
|
||||||
{report.findings.length} findings ·{" "}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{new Date(report.timestamp).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -296,15 +292,15 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
>
|
>
|
||||||
Sign release
|
Sign release
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
<ReportSummary
|
<ReportSummary
|
||||||
summary={report.summary}
|
summary={report.summary}
|
||||||
reviewedCount={reviewedCount}
|
reviewedCount={reviewedCount}
|
||||||
creditsSpent={creditsSpent}
|
creditsSpent={creditsSpent}
|
||||||
totalCostUsd={totalCostUsd}
|
totalCostUsd={totalCostUsd}
|
||||||
/>
|
/>
|
||||||
<ProtocolSection section={report.protocol_certification} />
|
|
||||||
{failedReviews > 0 && (
|
{failedReviews > 0 && (
|
||||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
|
<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">
|
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||||
@@ -344,12 +340,12 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{report.summary.total === 0 ? (
|
{report.summary.total === 0 &&
|
||||||
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
|
!(report.protocol_certification?.recognized_instances?.length) ? (
|
||||||
<p className="text-sm font-medium">No findings</p>
|
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[15px] font-medium tracking-tight">Nessun finding</p>
|
||||||
No IC datasheets were available for review. Upload datasheets for each IC
|
<p className="mt-1 text-[13px] text-neutral-500">
|
||||||
on the project page and re-run the pipeline to get findings.
|
Carica i datasheet degli IC sulla pagina progetto e rilancia l’esame.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -370,6 +366,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
reportedFindingIds={reportedFindingIds}
|
reportedFindingIds={reportedFindingIds}
|
||||||
reviews={reviews}
|
reviews={reviews}
|
||||||
onReviewSaved={handleReviewSaved}
|
onReviewSaved={handleReviewSaved}
|
||||||
|
protocolSection={report.protocol_certification}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
|
|
||||||
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
|
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
|
||||||
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
|
system-ui, "Segoe UI", sans-serif;
|
||||||
|
--font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, monospace;
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-headline: var(--font-dm-serif-display), Georgia, serif;
|
--font-headline: var(--font-sans);
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
@@ -53,15 +54,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.6875rem;
|
||||||
--background: oklch(1 0 0);
|
--background: #f5f5f7;
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: #1d1d1f;
|
||||||
--card: oklch(1 0 0);
|
--card: #ffffff;
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: #1d1d1f;
|
||||||
--popover: oklch(1 0 0);
|
--popover: #ffffff;
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: #1d1d1f;
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: #007aff;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: #ffffff;
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
@@ -69,22 +70,22 @@
|
|||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: rgba(0, 0, 0, 0.1);
|
||||||
--input: oklch(0.922 0 0);
|
--input: rgba(0, 0, 0, 0.1);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: #007aff;
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: #f5f5f7;
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: #1d1d1f;
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: #007aff;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: #ffffff;
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: rgba(0, 122, 255, 0.12);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: #007aff;
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: rgba(0, 0, 0, 0.1);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: #007aff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -129,7 +130,8 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
body {
|
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 type { Metadata, Viewport } from "next";
|
||||||
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
|
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
|
||||||
@@ -13,22 +12,6 @@ import {
|
|||||||
TWITTER_HANDLE,
|
TWITTER_HANDLE,
|
||||||
} from "@/lib/site";
|
} 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 = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
title: {
|
title: {
|
||||||
@@ -109,7 +92,7 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
|
className="h-full antialiased"
|
||||||
>
|
>
|
||||||
<body className="h-full">
|
<body className="h-full">
|
||||||
<ThemeProvider>
|
<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 { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
BookOpen,
|
||||||
Boxes,
|
|
||||||
CircuitBoard,
|
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
LayoutDashboard,
|
Folder,
|
||||||
Library,
|
Library,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Ruler,
|
ScanLine,
|
||||||
ScrollText,
|
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
TableProperties,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||||
@@ -72,15 +67,15 @@ export function Sidebar() {
|
|||||||
const isAdmin = user?.isAdmin ?? false;
|
const isAdmin = user?.isAdmin ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
|
<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="px-4 py-4 border-b border-border">
|
<div className="border-b border-black/10 px-3 py-3 dark:border-white/10">
|
||||||
<Link href="/dashboard" className="flex items-center gap-2">
|
<Link href="/dashboard" className="flex items-center gap-2">
|
||||||
<PeriscopeMark className="h-5 w-5" />
|
<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>
|
</Link>
|
||||||
</div>
|
</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 ? (
|
{projectId ? (
|
||||||
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
|
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
|
||||||
<ProjectNav
|
<ProjectNav
|
||||||
@@ -95,23 +90,23 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border">
|
<div className="border-t border-black/10 dark:border-white/10">
|
||||||
<SidebarCredits />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFeedbackOpen(true)}
|
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" />
|
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||||
Feedback
|
Feedback
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 />
|
<SidebarUserButton />
|
||||||
<Link
|
<Link
|
||||||
href="/changelog"
|
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}
|
v{APP_VERSION}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -125,36 +120,35 @@ export function Sidebar() {
|
|||||||
|
|
||||||
function navItemClass(active: boolean) {
|
function navItemClass(active: boolean) {
|
||||||
return cn(
|
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
|
active
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-[#007aff]/12 text-[#007aff]"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
: "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 }) {
|
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
|
||||||
return (
|
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")}>
|
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<Folder className="h-3.5 w-3.5" />
|
||||||
Projects
|
Progetti
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/library" className={navItemClass(pathname === "/library")}>
|
<Link href="/library" className={navItemClass(pathname === "/library")}>
|
||||||
<Library className="h-4 w-4" />
|
<Library className="h-3.5 w-3.5" />
|
||||||
Library
|
Libreria
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
|
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
|
||||||
<MessageSquareWarning className="h-4 w-4" />
|
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||||
My Feedback
|
Feedback
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
|
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
|
||||||
>
|
>
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-3.5 w-3.5" />
|
||||||
Admin
|
Admin
|
||||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -162,19 +156,18 @@ function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NavItem =
|
type NavItem =
|
||||||
| { type: "route"; path: 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; adminOnly?: boolean };
|
| { 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: "/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 },
|
const SECONDARY_NAV: NavItem[] = [
|
||||||
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
|
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
|
||||||
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
|
{ type: "tab", tab: "settings", label: "Impostazioni", icon: Settings },
|
||||||
{ 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 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function NavLink({
|
function NavLink({
|
||||||
@@ -220,8 +213,11 @@ function ProjectNav({
|
|||||||
const isRunning = project?.status === "running";
|
const isRunning = project?.status === "running";
|
||||||
const isOnProgress = pathname === `${base}/progress`;
|
const isOnProgress = pathname === `${base}/progress`;
|
||||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||||
|
const onReport = pathname === `${base}/report`;
|
||||||
|
|
||||||
function isActive(item: NavItem): boolean {
|
function isActive(item: NavItem): boolean {
|
||||||
|
if (item.label === "Protocolli") return onReport;
|
||||||
|
if (item.label === "Report") return onReport;
|
||||||
if (item.type === "route") {
|
if (item.type === "route") {
|
||||||
return pathname === `${base}${item.path}` && !currentTab;
|
return pathname === `${base}${item.path}` && !currentTab;
|
||||||
}
|
}
|
||||||
@@ -232,62 +228,66 @@ function ProjectNav({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHref(item: NavItem): string {
|
function getHref(item: NavItem): string {
|
||||||
|
if (item.label === "Protocolli") return `${base}/report`;
|
||||||
if (item.type === "route") return `${base}${item.path}`;
|
if (item.type === "route") return `${base}${item.path}`;
|
||||||
return `${base}?tab=${item.tab}`;
|
return `${base}?tab=${item.tab}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderItem(item: NavItem) {
|
||||||
|
const active = isActive(item);
|
||||||
|
const href = getHref(item);
|
||||||
|
const forceDocument = item.type === "tab" && onNestedRoute;
|
||||||
|
if (isRunning) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={item.label}
|
||||||
|
className="flex cursor-not-allowed items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] text-neutral-400"
|
||||||
|
>
|
||||||
|
<item.icon className="h-3.5 w-3.5" />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
|
||||||
|
<item.icon className="h-3.5 w-3.5" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 px-2 py-3 space-y-1">
|
<nav className="flex-1 space-y-1 px-2 py-3">
|
||||||
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
|
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<Folder className="h-3.5 w-3.5" />
|
||||||
Dashboard
|
Progetti
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/library" forceDocument={onNestedRoute}>
|
<NavLink href="/library" forceDocument={onNestedRoute}>
|
||||||
<Library className="h-4 w-4" />
|
<Library className="h-3.5 w-3.5" />
|
||||||
Library
|
Libreria
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<div className="px-3 pt-3 pb-1">
|
<p className="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
|
||||||
<p className="text-xs font-semibold text-foreground truncate">
|
{project?.name ?? "…"}
|
||||||
{project?.name ?? "Loading..."}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
Processing
|
Esame in corso
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
|
{PRIMARY_NAV.map(renderItem)}
|
||||||
const active = isActive(item);
|
</div>
|
||||||
const href = getHref(item);
|
<div className="mt-3 space-y-0.5 border-t border-black/10 pt-2 dark:border-white/10">
|
||||||
const forceDocument = item.type === "tab" && onNestedRoute;
|
{SECONDARY_NAV.map(renderItem)}
|
||||||
if (isRunning) {
|
{isAdmin ? (
|
||||||
return (
|
<NavLink href={`${base}?tab=logs`} forceDocument={onNestedRoute}>
|
||||||
<span
|
<Shield className="h-3.5 w-3.5" />
|
||||||
key={item.label}
|
Log
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
|
</NavLink>
|
||||||
>
|
) : null}
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
{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.label}
|
|
||||||
{item.adminOnly && (
|
|
||||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
FindingStatus,
|
FindingStatus,
|
||||||
DesignGraph,
|
DesignGraph,
|
||||||
Collaborator,
|
Collaborator,
|
||||||
|
ProtocolCertificationSection,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||||
import { isLayoutFinding } from "@/lib/layout-finding";
|
import { isLayoutFinding } from "@/lib/layout-finding";
|
||||||
@@ -33,6 +34,7 @@ interface FindingsListProps {
|
|||||||
reportedFindingIds?: Set<string>;
|
reportedFindingIds?: Set<string>;
|
||||||
reviews?: Record<string, FindingReview>;
|
reviews?: Record<string, FindingReview>;
|
||||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||||
|
protocolSection?: ProtocolCertificationSection | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||||
@@ -54,6 +56,7 @@ export function FindingsList({
|
|||||||
reportedFindingIds,
|
reportedFindingIds,
|
||||||
reviews,
|
reviews,
|
||||||
onReviewSaved,
|
onReviewSaved,
|
||||||
|
protocolSection,
|
||||||
}: FindingsListProps) {
|
}: FindingsListProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -161,8 +164,11 @@ export function FindingsList({
|
|||||||
reportedFindingIds,
|
reportedFindingIds,
|
||||||
reviews,
|
reviews,
|
||||||
onReviewSaved,
|
onReviewSaved,
|
||||||
|
protocolSection,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showTree = filtered.length > 0 || (protocolSection?.recognized_instances?.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ReportFilters
|
<ReportFilters
|
||||||
@@ -180,7 +186,7 @@ export function FindingsList({
|
|||||||
onDomainChange={(v) => updateParams({ domain: v })}
|
onDomainChange={(v) => updateParams({ domain: v })}
|
||||||
designators={designators}
|
designators={designators}
|
||||||
/>
|
/>
|
||||||
{filtered.length > 0 ? (
|
{showTree ? (
|
||||||
<FindingsTree findings={filtered} {...treeProps} />
|
<FindingsTree findings={filtered} {...treeProps} />
|
||||||
) : reviewedFindings.length === 0 && findings.length > 0 ? (
|
) : reviewedFindings.length === 0 && findings.length > 0 ? (
|
||||||
<FindingsTreeEmpty />
|
<FindingsTreeEmpty />
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react";
|
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 { FindingCard } from "./finding-card";
|
||||||
import { StatusBadge } from "./status-badge";
|
import { ProtocolInstanceBody } from "./protocol-section";
|
||||||
import type {
|
import type {
|
||||||
Finding,
|
Finding,
|
||||||
FindingComment,
|
FindingComment,
|
||||||
@@ -12,10 +10,21 @@ import type {
|
|||||||
Collaborator,
|
Collaborator,
|
||||||
Component,
|
Component,
|
||||||
DesignGraph,
|
DesignGraph,
|
||||||
|
FindingStatus,
|
||||||
|
ProtocolCertificationSection,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||||
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
import {
|
||||||
import { groupByWorstStatus, worstStatus } from "@/lib/findings-forest";
|
STATUS_TINT,
|
||||||
|
forestHasCertifications,
|
||||||
|
groupByWorstStatus,
|
||||||
|
isProtocolFinding,
|
||||||
|
protocolInstancesFromSection,
|
||||||
|
protocolInstancesForStatus,
|
||||||
|
worstStatus,
|
||||||
|
type DomainGroup,
|
||||||
|
type ProtocolTreeInstance,
|
||||||
|
} from "@/lib/findings-forest";
|
||||||
|
|
||||||
interface FindingsTreeProps {
|
interface FindingsTreeProps {
|
||||||
findings: Finding[];
|
findings: Finding[];
|
||||||
@@ -35,6 +44,9 @@ interface FindingsTreeProps {
|
|||||||
reportedFindingIds?: Set<string>;
|
reportedFindingIds?: Set<string>;
|
||||||
reviews?: Record<string, FindingReview>;
|
reviews?: Record<string, FindingReview>;
|
||||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||||
|
protocolSection?: ProtocolCertificationSection | null;
|
||||||
|
selectedKey?: string | null;
|
||||||
|
onSelect?: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pinNetLabel(f: Finding): string {
|
function pinNetLabel(f: Finding): string {
|
||||||
@@ -46,73 +58,126 @@ function pinNetLabel(f: Finding): string {
|
|||||||
return f.rule_id || "Finding";
|
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({
|
function TreeBranch({
|
||||||
label,
|
label,
|
||||||
extra,
|
extra,
|
||||||
findings,
|
findings,
|
||||||
children,
|
children,
|
||||||
|
tint,
|
||||||
|
defaultOpen = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
extra?: string;
|
extra?: string;
|
||||||
findings: Finding[];
|
findings: { status: FindingStatus }[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
tint?: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const worst = worstStatus(findings);
|
const worst = worstStatus(findings);
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
<div>
|
||||||
<CollapsibleTrigger
|
<button
|
||||||
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"
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"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,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<DisclosureMark open={open} />
|
||||||
className={cn(
|
<span className="min-w-0 truncate font-medium tracking-tight">{label}</span>
|
||||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
|
||||||
open && "rotate-90",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-sm truncate">{label}</span>
|
|
||||||
{extra ? (
|
{extra ? (
|
||||||
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
|
<span className="hidden min-w-0 truncate text-[11px] text-neutral-500 sm:inline">
|
||||||
|
{extra}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
|
||||||
<StatusBadge status={worst} />
|
{findings.length}
|
||||||
<span className="text-xs text-muted-foreground">{findings.length}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</CollapsibleTrigger>
|
<span className="sr-only">{worst}</span>
|
||||||
<CollapsibleContent>
|
</button>
|
||||||
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
|
{open ? <div className="ml-[13px] border-l border-black/10 pl-2 dark:border-white/10">{children}</div> : null}
|
||||||
</CollapsibleContent>
|
</div>
|
||||||
</Collapsible>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FindingsTree(props: FindingsTreeProps) {
|
export function FindingsTree(props: FindingsTreeProps) {
|
||||||
const sorted = sortFindings(props.findings);
|
const sorted = sortFindings(props.findings);
|
||||||
const af = sorted.filter(isAfAiFinding);
|
const protocolFindings = sorted.filter(isProtocolFinding);
|
||||||
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
|
const rest = sorted.filter((f) => !isProtocolFinding(f));
|
||||||
const schematic = sorted.filter(
|
const folders = groupByWorstStatus(rest.length ? rest : sorted.filter((f) => !isProtocolFinding(f)));
|
||||||
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
|
const instances = protocolInstancesFromSection(props.protocolSection);
|
||||||
);
|
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||||
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 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 (
|
return (
|
||||||
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
|
<nav
|
||||||
{roots.map((root) => {
|
aria-label="Findings tree"
|
||||||
const body = (
|
className="overflow-hidden rounded-[11px] border border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.04]"
|
||||||
<DesignatorForest
|
>
|
||||||
{...props}
|
{merged.map(({ status, folder, cert }, index) => {
|
||||||
findings={root.items}
|
const label =
|
||||||
/>
|
status === "ERROR" ? "Error" : status === "WARNING" ? "Warning" : "Info";
|
||||||
);
|
const findings = [
|
||||||
if (!showRoots) return <div key={root.id}>{body}</div>;
|
...(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 (
|
return (
|
||||||
<TreeBranch key={root.id} label={root.label} findings={root.items}>
|
<TreeBranch
|
||||||
{body}
|
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}
|
||||||
|
status={status}
|
||||||
|
instances={inst}
|
||||||
|
findings={protocolAtStatus}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</TreeBranch>
|
</TreeBranch>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -120,25 +185,18 @@ export function FindingsTree(props: FindingsTreeProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesignatorForest({
|
function DomainBranch({
|
||||||
findings,
|
domain,
|
||||||
graph,
|
...props
|
||||||
...cardProps
|
}: { domain: DomainGroup } & FindingsTreeProps) {
|
||||||
}: FindingsTreeProps) {
|
|
||||||
const folders = groupByWorstStatus(findings);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<TreeBranch label={domain.label} findings={domain.findings}>
|
||||||
{folders.map((folder) => (
|
{domain.rules.map((rule) => (
|
||||||
<TreeBranch
|
<TreeBranch key={rule.ruleId} label={rule.ruleId} findings={rule.findings}>
|
||||||
key={folder.status}
|
{rule.designators.map((row) => {
|
||||||
label={folder.label}
|
|
||||||
findings={folder.findings}
|
|
||||||
>
|
|
||||||
{folder.designators.map((row) => {
|
|
||||||
const d = row.designator;
|
const d = row.designator;
|
||||||
const group = row.findings;
|
const group = row.findings;
|
||||||
const component: Component | undefined = graph.components[d];
|
const component: Component | undefined = props.graph.components[d];
|
||||||
const extra = [
|
const extra = [
|
||||||
component?.mpn,
|
component?.mpn,
|
||||||
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
|
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
|
||||||
@@ -157,31 +215,31 @@ function DesignatorForest({
|
|||||||
<TreeBranch key={d} label={d} extra={extra} findings={group}>
|
<TreeBranch key={d} label={d} extra={extra} findings={group}>
|
||||||
{leaves.map(([label, items]) => (
|
{leaves.map(([label, items]) => (
|
||||||
<TreeBranch key={`${d}:${label}`} label={label} findings={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) => {
|
{sortFindings(items).map((item) => {
|
||||||
const key = cardProps.findingKeys.get(item);
|
const key = props.findingKeys.get(item);
|
||||||
return (
|
return (
|
||||||
<FindingCard
|
<FindingCard
|
||||||
key={key}
|
key={key}
|
||||||
finding={item}
|
finding={item}
|
||||||
onViewReference={cardProps.onViewReference}
|
onViewReference={props.onViewReference}
|
||||||
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
|
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
|
||||||
onCheckedChange={
|
onCheckedChange={
|
||||||
key && cardProps.onToggleReviewed
|
key && props.onToggleReviewed
|
||||||
? () => cardProps.onToggleReviewed!(key)
|
? () => props.onToggleReviewed!(key)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
comments={item.finding_id ? cardProps.comments?.[item.finding_id] : undefined}
|
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
|
||||||
projectId={cardProps.projectId}
|
projectId={props.projectId}
|
||||||
collaborators={cardProps.collaborators}
|
collaborators={props.collaborators}
|
||||||
currentUserId={cardProps.currentUserId}
|
currentUserId={props.currentUserId}
|
||||||
currentUserName={cardProps.currentUserName}
|
currentUserName={props.currentUserName}
|
||||||
onCommentAdded={cardProps.onCommentAdded}
|
onCommentAdded={props.onCommentAdded}
|
||||||
onCommentDeleted={cardProps.onCommentDeleted}
|
onCommentDeleted={props.onCommentDeleted}
|
||||||
onReportFinding={cardProps.onReportFinding}
|
onReportFinding={props.onReportFinding}
|
||||||
isReported={!!(item.finding_id && cardProps.reportedFindingIds?.has(item.finding_id))}
|
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
|
||||||
review={item.finding_id ? cardProps.reviews?.[item.finding_id] : undefined}
|
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
|
||||||
onReviewSaved={cardProps.onReviewSaved}
|
onReviewSaved={props.onReviewSaved}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -193,14 +251,88 @@ function DesignatorForest({
|
|||||||
})}
|
})}
|
||||||
</TreeBranch>
|
</TreeBranch>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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() {
|
export function FindingsTreeEmpty() {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground text-center py-12">
|
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
|
||||||
No findings match your filters.
|
<p className="text-[15px] font-medium tracking-tight">Nessun finding visibile</p>
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import type { ProtocolCertificationSection } from "@/lib/types";
|
import type { ProtocolCertificationSection } from "@/lib/types";
|
||||||
import { StatusBadge } from "@/components/report/status-badge";
|
|
||||||
import {
|
import {
|
||||||
formatLimit,
|
formatLimit,
|
||||||
formatMargin,
|
formatMargin,
|
||||||
formatMeasured,
|
formatMeasured,
|
||||||
formatProtocolChain,
|
formatProtocolChain,
|
||||||
protocolResultToFindingStatus,
|
|
||||||
sortProtocolChecks,
|
sortProtocolChecks,
|
||||||
type ProtocolReportCheck,
|
type ProtocolReportCheck,
|
||||||
} from "@/lib/protocol-report";
|
} from "@/lib/protocol-report";
|
||||||
|
import {
|
||||||
|
protocolInstancesFromSection,
|
||||||
|
type ProtocolTreeInstance,
|
||||||
|
} from "@/lib/findings-forest";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
|
export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInstance }) {
|
||||||
const raw = row.report_checks;
|
const checks = sortProtocolChecks(instance.checks);
|
||||||
if (Array.isArray(raw) && raw.length > 0) {
|
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
|
||||||
return sortProtocolChecks(raw as ProtocolReportCheck[]);
|
const logical = instance.logicalId;
|
||||||
}
|
const physical = instance.physicalId;
|
||||||
const fallback: ProtocolReportCheck[] = [];
|
return (
|
||||||
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
|
<div className="space-y-2 py-2 pr-2">
|
||||||
const list = row[key];
|
<p className="text-[11px] tabular-nums text-neutral-500">
|
||||||
if (!Array.isArray(list)) continue;
|
{logical} → {physical}
|
||||||
for (const c of list) {
|
{instance.recognition ? ` · ${instance.recognition}` : ""}
|
||||||
const rec = c as Record<string, unknown>;
|
{instance.worst ? ` · ${instance.worst}` : ""}
|
||||||
fallback.push({
|
</p>
|
||||||
check: String(rec.check ?? ""),
|
{fail ? (
|
||||||
level: String(rec.level ?? ""),
|
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL in testa — non sotto Warning.</p>
|
||||||
result: String(rec.result ?? ""),
|
) : null}
|
||||||
notes: String(rec.notes ?? ""),
|
<div className="overflow-x-auto">
|
||||||
skip_visible:
|
<table className="w-full text-[11px] tabular-nums">
|
||||||
String(rec.result ?? "") !== "PASS" &&
|
<thead>
|
||||||
String(rec.result ?? "") !== "FAIL",
|
<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>
|
||||||
return sortProtocolChecks(fallback);
|
<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) => (
|
||||||
|
<ProtocolCheckRow
|
||||||
|
key={`${c.level}-${c.check}-${j}`}
|
||||||
|
check={c}
|
||||||
|
logicalId={logical}
|
||||||
|
physicalId={physical}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{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="break-all text-[11px] text-neutral-500">
|
||||||
|
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
|
||||||
|
</p>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</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({
|
export function ProtocolSection({
|
||||||
section,
|
section,
|
||||||
}: {
|
}: {
|
||||||
section?: ProtocolCertificationSection | null;
|
section?: ProtocolCertificationSection | null;
|
||||||
}) {
|
}) {
|
||||||
const instances = section?.recognized_instances ?? [];
|
const instances = protocolInstancesFromSection(section);
|
||||||
const message = section?.message || "nessun protocollo riconosciuto";
|
const message = section?.message || "Nessun protocollo riconosciuto.";
|
||||||
const maxLevel = section?.max_level_reached || "—";
|
const maxLevel = section?.max_level_reached || "—";
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-border bg-card p-4 space-y-3">
|
<section className="space-y-2">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<p className="text-[11px] text-neutral-500">
|
||||||
<h2 className="text-sm font-semibold">Protocolli</h2>
|
Livello massimo {maxLevel} (L0–L3). HDMI 1.4 TMDS resta UNKNOWN — niente Zdiff inventato.
|
||||||
<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)
|
|
||||||
</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>
|
</p>
|
||||||
{instances.length === 0 ? (
|
{instances.length === 0 ? (
|
||||||
<p className="text-sm">{message}</p>
|
<p className="text-[13px] text-neutral-600">{message}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-3">
|
||||||
{instances.map((row, i) => {
|
{instances.map((inst) => (
|
||||||
const rec = row as Record<string, unknown>;
|
<li key={inst.instanceId}>
|
||||||
const phys = String(rec.physical_interface_id ?? "");
|
<ProtocolInstanceBody instance={inst} />
|
||||||
const log = String(rec.logical_protocol_id ?? "");
|
</li>
|
||||||
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>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<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>
|
|
||||||
<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
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{String(rec.chain_example || "") ? (
|
|
||||||
<p className="text-[11px] text-muted-foreground break-all">
|
|
||||||
Catena: {String(rec.chain_example)}
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ReportSummaryProps {
|
interface ReportSummaryProps {
|
||||||
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
|
|||||||
totalCostUsd?: number | null;
|
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({
|
export function ReportSummary({
|
||||||
summary,
|
summary,
|
||||||
reviewedCount,
|
reviewedCount,
|
||||||
creditsSpent,
|
creditsSpent,
|
||||||
totalCostUsd,
|
totalCostUsd,
|
||||||
}: ReportSummaryProps) {
|
}: ReportSummaryProps) {
|
||||||
const total = summary.total || 0;
|
const errors = summary.ERROR ?? 0;
|
||||||
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
|
const warnings = summary.WARNING ?? 0;
|
||||||
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
|
const infos = summary.INFO ?? 0;
|
||||||
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
|
const total = summary.total || errors + warnings + infos;
|
||||||
const grid =
|
const outcome =
|
||||||
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<p className={cn("text-[13px] tabular-nums text-neutral-500")}>
|
||||||
<div className={cn("grid gap-4", grid)}>
|
<span className="font-medium text-neutral-800 dark:text-neutral-200">{outcome}</span>
|
||||||
{CELLS.map(({ key, label, color }) => (
|
{" · "}
|
||||||
<Card key={key}>
|
{bits.join(" · ")}
|
||||||
<CardContent className="pt-4 pb-4">
|
</p>
|
||||||
<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>
|
|
||||||
</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 type { FindingStatus } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Footnote-weight status. Light fill only (~7%), never a saturated badge. */
|
||||||
const TONE: Record<FindingStatus, string> = {
|
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",
|
ERROR: "bg-[rgba(255,59,48,0.07)] text-[rgb(160,30,28)]",
|
||||||
WARNING: "bg-amber-500/10 text-amber-600 border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-400",
|
WARNING: "bg-[rgba(255,214,10,0.08)] text-[rgb(140,100,20)]",
|
||||||
INFO: "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-400",
|
INFO: "bg-[rgba(142,142,147,0.08)] text-[rgb(70,70,78)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: FindingStatus }) {
|
export function StatusBadge({ status }: { status: FindingStatus }) {
|
||||||
return (
|
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}
|
{status}
|
||||||
</Badge>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { groupByWorstStatus, worstStatus } from "./findings-forest.ts";
|
import { groupByWorstStatus, groupDomains, isProtocolFinding, worstStatus } from "./findings-forest";
|
||||||
import { isPcbExamFinding } from "./layout-finding.ts";
|
import { isPcbExamFinding } from "./layout-finding";
|
||||||
import type { Finding } from "./types.ts";
|
import type { Finding } from "./types";
|
||||||
|
|
||||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||||
return {
|
return {
|
||||||
@@ -50,6 +50,28 @@ test("Error folder lists J2 connectors, not only U*", () => {
|
|||||||
assert.equal(groups[1].label, "Warning");
|
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", () => {
|
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
|
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";
|
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
|
||||||
|
import {
|
||||||
const RANK: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
instanceWorstResult,
|
||||||
|
protocolResultToFindingStatus,
|
||||||
|
type ProtocolReportCheck,
|
||||||
|
} from "./protocol-report";
|
||||||
|
|
||||||
export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
||||||
ERROR: "Error",
|
ERROR: "Error",
|
||||||
@@ -10,6 +13,46 @@ export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
|||||||
INFO: "Info",
|
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 {
|
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
|
||||||
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
|
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
|
||||||
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
|
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
|
||||||
@@ -21,11 +64,38 @@ export type DesignatorGroup = {
|
|||||||
findings: Finding[];
|
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 = {
|
export type StatusGroup = {
|
||||||
status: FindingStatus;
|
status: FindingStatus;
|
||||||
label: string;
|
label: string;
|
||||||
findings: Finding[];
|
findings: Finding[];
|
||||||
designators: DesignatorGroup[];
|
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. */
|
/** 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 }));
|
.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.
|
* 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.
|
* 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[] {
|
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
|
||||||
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
|
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
|
||||||
@@ -60,11 +189,80 @@ export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
|
|||||||
.filter((status) => buckets[status].length > 0)
|
.filter((status) => buckets[status].length > 0)
|
||||||
.map((status) => {
|
.map((status) => {
|
||||||
const designators = buckets[status];
|
const designators = buckets[status];
|
||||||
|
const folderFindings = designators.flatMap((d) => d.findings);
|
||||||
|
const nonProtocol = folderFindings.filter((f) => !isProtocolFinding(f));
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
label: STATUS_FOLDER[status],
|
label: STATUS_FOLDER[status],
|
||||||
findings: designators.flatMap((d) => d.findings),
|
findings: folderFindings,
|
||||||
designators,
|
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 assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
formatLimit,
|
||||||
|
formatMeasured,
|
||||||
formatProtocolChain,
|
formatProtocolChain,
|
||||||
|
isHdmi14TmdsElectrical,
|
||||||
instanceWorstResult,
|
instanceWorstResult,
|
||||||
protocolResultRank,
|
protocolResultRank,
|
||||||
protocolResultToFindingStatus,
|
protocolResultToFindingStatus,
|
||||||
sortProtocolChecks,
|
sortProtocolChecks,
|
||||||
} from "./protocol-report.ts";
|
} from "./protocol-report";
|
||||||
import { groupByWorstStatus } from "./findings-forest.ts";
|
import { groupByWorstStatus } from "./findings-forest";
|
||||||
import type { Finding } from "./types.ts";
|
import type { Finding } from "./types";
|
||||||
|
|
||||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||||
return {
|
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.equal(groups[0].status, "ERROR");
|
||||||
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
|
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(" → ");
|
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 "—";
|
if (row.measured == null) return "—";
|
||||||
const unit = row.unit ? ` ${row.unit}` : "";
|
const unit = row.unit ? ` ${row.unit}` : "";
|
||||||
return `${row.measured}${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 "—";
|
if (row.limit == null) return "—";
|
||||||
const unit = row.unit ? ` ${row.unit}` : "";
|
const unit = row.unit ? ` ${row.unit}` : "";
|
||||||
return `${row.limit}${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 "—";
|
if (row.margin == null) return "—";
|
||||||
const unit = row.unit ? ` ${row.unit}` : "";
|
const unit = row.unit ? ` ${row.unit}` : "";
|
||||||
return `${row.margin}${unit}`;
|
return `${row.margin}${unit}`;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
/** 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";
|
export const APP_VERSION_DATE = "2026-09-22";
|
||||||
|
|||||||
Reference in New Issue
Block a user