Files
periscope/frontend/src/components/report/findings-list.tsx
T
michele 16c606ae3d Add MODE=pcb layout exam job with AI and deterministic checks.
Parallel pcb_status pipeline: parse board, classify domains/groups,
inventory traces, PE-LAY/PLC/SI/DRT checks, per-IC datasheet review.
Findings merge into the report UI. No auto-place or pcbnew write-back.
2026-09-19 22:54:17 +02:00

196 lines
7.4 KiB
TypeScript

"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useCallback, useMemo } from "react";
import { ComponentGroup } from "./component-group";
import { ReportFilters } from "./report-filters";
import { ReviewedSection } from "./reviewed-section";
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
interface FindingsListProps {
findings: Finding[];
graph: DesignGraph;
onViewReference: (finding: Finding) => void;
projectId: string;
isReviewed: (key: string) => boolean;
toggleReviewed: (key: string) => void;
comments?: Record<string, FindingComment[]>;
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const findingKeyMap = useMemo(() => {
const map = new Map<Finding, string>();
findings.forEach((f, i) => map.set(f, getFindingKey(f, i)));
return map;
}, [findings]);
const statusParam = searchParams.get("status");
const componentParam = searchParams.get("component");
const reviewParam = searchParams.get("review");
const searchParam = searchParams.get("q") ?? "";
const domainParam = searchParams.get("domain");
const statusFilters = useMemo(() => {
if (!statusParam) return new Set<FindingStatus>(["ERROR", "WARNING", "INFO"]);
return new Set(statusParam.split(",") as FindingStatus[]);
}, [statusParam]);
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === "") params.delete(key);
else params.set(key, value);
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[searchParams, router, pathname]
);
const toggleStatus = useCallback(
(status: FindingStatus) => {
const next = new Set(statusFilters);
if (next.has(status)) next.delete(status);
else next.add(status);
const isDefault = next.size === 3 && next.has("ERROR") && next.has("WARNING") && next.has("INFO");
const value = isDefault ? null : Array.from(next).join(",");
updateParams({ status: value });
},
[statusFilters, updateParams]
);
const matchesFilters = useCallback(
(f: Finding) => {
if (!statusFilters.has(f.status)) return false;
if (componentParam && componentParam !== "all" && f.designator !== componentParam)
return false;
const q = searchParam.toLowerCase();
if (q && !f.finding.toLowerCase().includes(q) && !(f.why ?? "").toLowerCase().includes(q))
return false;
if (reviewParam === "open") {
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
if (st && st !== "open") return false;
}
if (domainParam === "layout") {
const layout =
(f.finding_id || "").startsWith("PCB-") ||
f.source === "pcb_review" ||
(f.rule_id || "").startsWith("PE-PLC") ||
(f.rule_id || "").startsWith("PE-LAY") ||
(f.rule_id || "").startsWith("PE-SI") ||
(f.rule_id || "").startsWith("PE-DRT");
if (!layout) return false;
} else if (domainParam === "schema") {
const layout =
(f.finding_id || "").startsWith("PCB-");
if (layout) return false;
}
return true;
},
[statusFilters, componentParam, searchParam, reviewParam, reviews, domainParam]
);
const filtered = useMemo(() => {
return findings.filter((f) => {
if (!matchesFilters(f)) return false;
const key = findingKeyMap.get(f)!;
return !isReviewed(key);
});
}, [findings, matchesFilters, findingKeyMap, isReviewed]);
const reviewedFindings = useMemo(() => {
return findings.filter((f) => {
if (!matchesFilters(f)) return false;
const key = findingKeyMap.get(f)!;
return isReviewed(key);
});
}, [findings, matchesFilters, findingKeyMap, isReviewed]);
const grouped = useMemo(() => groupBy(filtered, (f) => f.designator), [filtered]);
const designators = useMemo(() => {
const all = [...new Set(findings.map((f) => f.designator))];
const byDesignator = groupBy(findings, (f) => f.designator);
const worst = (d: string): number => {
const group = byDesignator[d] ?? [];
if (group.some((f) => f.status === "ERROR")) return 0;
if (group.some((f) => f.status === "WARNING")) return 1;
return 2;
};
return all.sort((a, b) => worst(a) - worst(b) || a.localeCompare(b));
}, [findings]);
return (
<div className="space-y-4">
<ReportFilters
statusFilters={statusFilters}
onToggleStatus={toggleStatus}
componentFilter={componentParam ?? "all"}
onComponentChange={(v) => updateParams({ component: v === "all" ? null : v })}
search={searchParam}
onSearchChange={(v) => updateParams({ q: v || null })}
needsReview={reviewParam === "open"}
onToggleNeedsReview={() =>
updateParams({ review: reviewParam === "open" ? null : "open" })
}
domain={domainParam ?? "all"}
onDomainChange={(v) => updateParams({ domain: v })}
designators={designators}
/>
<div className="space-y-1">
{designators
.filter((d) => grouped[d])
.map((d) => (
<ComponentGroup
key={d}
designator={d}
findings={grouped[d]}
component={graph.components[d]}
onViewReference={onViewReference}
findingKeys={findingKeyMap}
isReviewed={isReviewed}
onToggleReviewed={toggleReviewed}
comments={comments}
projectId={projectId}
collaborators={collaborators}
currentUserId={currentUserId}
currentUserName={currentUserName}
onCommentAdded={onCommentAdded}
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={onReviewSaved}
/>
))}
{filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
<p className="text-sm text-muted-foreground text-center py-12">
No findings match your filters.
</p>
)}
</div>
{reviewedFindings.length > 0 && (
<ReviewedSection
findings={reviewedFindings}
findingKeys={findingKeyMap}
onToggleReviewed={toggleReviewed}
onViewReference={onViewReference}
/>
)}
</div>
);
}