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.
196 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|