import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import { previousPolicyReviews } from '../review-workspace/database-review'; import { useApp } from '../../app/AppProvider'; import { TriangleIcon } from '../../components/TriangleIcon'; import { DataTable } from '../../components/DataTable/DataTable'; import { FindingCard } from '../../components/FindingCard/FindingCard'; import { TileCard } from '../../components/TileCard/TileCard'; import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace'; import type { DatasetSheet } from '../../domain/dataset'; import type { FindingRecord } from '../../domain/finding'; import { resultReferenceValues } from '../../profiles/purple-knight/result-linker'; import { maesterFindingTitle } from '../../profiles/maester/presentation'; import { cisBenchmarkRuleId } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisSectionHeading } from '../../profiles/cis-cat/cis-benchmark-section-titles'; import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links'; import { recordResultSheets } from '../linked-results/record-results'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message'; import { FindingReader } from '../finding-reader/FindingReader'; import { ClusterSlotIcon, FloatingResultsWindow } from '../linked-results/FloatingResultsWindow'; import { resultClusterParkingPosition, RESULT_CLUSTER_ROW_HEIGHT, RESULT_DOCK_SLOT_ID, detachResultWindow, prepareForNewClusterWindow, restoreResultWindow, toggleResultWindowMinimized } from '../linked-results/result-window-layout'; import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { useVirtualRows } from '../../hooks/use-virtual-rows'; import { toolIdForRecord } from '../../importers/status-normalization'; /** * Geschaetzte Zeilenhoehe fuer die Fenster-Berechnung der Trefferliste * (Datensaetze-Ansicht). Dient nur dem Scroll-Mass der Platzhalter, nicht dem * tatsaechlichen Layout der sichtbaren Karten - eine Abweichung durch * zweizeilige Titel bleibt durch den Overscan unbemerkbar. */ const RECORD_ROW_HEIGHT = 78; /** Verbindliche Inhaltskachelbreite, siehe `--tile-content-width`. */ const TILE_MIN_WIDTH = 358; /** Verbindliche Mindesthoehe der regulaeren Finding-Kachel. */ const TILE_MIN_HEIGHT = 223; /** Einheitlicher Segmentabstand, siehe `--segment-gap`. */ const TILE_GAP = 12; /** `padding` von `.tile-grid` (--panel-inset-Token, 20px je Seite). */ const TILE_GRID_INSET = 20; /** Geschaetzte Kachelzeilenhoehe inklusive Zeilenabstand, aus demselben Grund * wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */ const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP; // Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster. // Enthielten Violet und Purple; beide ersetzt, weil violett als UI-Rolle // vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md // "UI segments and tiles"). const resultColors = [ '#0e7490', // Cyan '#059669', // Emerald '#dc2626', // Red '#2563eb', // Blue '#ea580c', // Orange '#0d9488', // Teal '#be185d', // Rose '#16a34a', // Green '#d946ef', // Magenta '#b45309', // Amber '#4f46e5', // Indigo '#ca8a04', // Gold '#0369a1', // Sky Blue '#78350f', // Brown '#65a30d', // Lime '#475569' // Slate ]; interface OpenResultWindow { recordId: string; /** Werkzeugprofil des einzelnen Findings, auch in generischen Sammelblaettern. */ toolId?: string; title: string; sheets: DatasetSheet[]; /** Ergebnistexte des Datensatzes (siehe `resultSectionsFor`). */ sections: ResultSection[]; color: string; zIndex: number; /** Nur die Kopfzeile sichtbar, manuell umgeschaltet. */ minimized: boolean; /** Maximiert und aus dem Cluster in eine freie Position gezogen. */ detached: boolean; } import { RawDataView } from './RawDataView'; import { measureNavigationStep } from '../../app/navigation-diagnostics'; export function DatasetViewer() { const app = useApp(); const { dataset, activeSheet, view, setView, search, titleColumn, selectedRecordId, setSelectedRecord, reviews, updateReview } = app; const [resultWindows, setResultWindows] = useState([]); const [clusterPos, setClusterPos] = useState<{ left: number; top: number } | null>(null); const [clusterWidth, setClusterWidth] = useState(760); /** * Eingeklappter Verbund: die Miniaturen verschwinden aus der Arbeitsflaeche * und liegen als schmale Leiste in der Filterzeile. Frei herausgezogene * Fenster bleiben davon unberuehrt - sie gehoeren nicht zum Verbund. */ const [clusterDocked, setClusterDocked] = useState(false); const [dockSlot, setDockSlot] = useState(null); const [viewportSize, setViewportSize] = useState(() => ({ width: window.innerWidth, height: window.innerHeight })); const parkingAnchorRef = useRef(null); const [workSurfaceTop, setWorkSurfaceTop] = useState(0); const nextZIndex = useRef(2100); const records = useFilteredRecords(); const minimizedWindows = useMemo(() => resultWindows.filter(w => w.minimized && !w.detached), [resultWindows]); const clusteredWindows = useMemo(() => resultWindows.filter(w => !w.detached), [resultWindows]); const effectiveClusterWidth = Math.min(clusterWidth, Math.max(0, viewportSize.width - 12)); const clusterStackHeight = Math.max(RESULT_CLUSTER_ROW_HEIGHT, minimizedWindows.length * RESULT_CLUSTER_ROW_HEIGHT); const maxClusterLeft = Math.max(0, viewportSize.width - effectiveClusterWidth); const maxClusterTop = Math.max(0, viewportSize.height - clusterStackHeight - 12); // Minimierte Results parken rechts oben innerhalb der Arbeitsflaeche statt // im darueberliegenden Filter- und Statusband. Der Cluster bleibt danach // frei verschiebbar, aber kann nicht in die Kopfzone zurueckgezogen werden. const defaultClusterPos = useMemo(() => resultClusterParkingPosition( viewportSize.width, effectiveClusterWidth, maxClusterTop, workSurfaceTop ), [effectiveClusterWidth, maxClusterTop, viewportSize.width, workSurfaceTop]); const minClusterTop = defaultClusterPos.top; const requestedClusterPos = clusterPos ?? defaultClusterPos; const effectiveClusterPos = { left: Math.max(0, Math.min(requestedClusterPos.left, maxClusterLeft)), top: Math.max(minClusterTop, Math.min(requestedClusterPos.top, maxClusterTop)) }; const resultColorsByRecord = useMemo(() => new Map(resultWindows.map(item => [item.recordId, item.color])), [resultWindows]); // Kacheln: die Spaltenzahl treibt sowohl das CSS-Grid als auch die // Fenstermathematik - deshalb hier gemessen statt `auto-fill` dem Browser // zu ueberlassen, das JS sonst nicht kennen wuerde. const tileScrollRef = useRef(null); const [tileColumns, setTileColumns] = useState(1); useLayoutEffect(() => { const container = tileScrollRef.current; if (!container || view !== 'columns') return; const compute = () => { const contentWidth = container.clientWidth - TILE_GRID_INSET * 2; if (container.clientWidth > 0) { setTileColumns(Math.max(1, Math.floor((contentWidth + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)))); } }; compute(); const resizeObserver = new ResizeObserver(compute); resizeObserver.observe(container); return () => resizeObserver.disconnect(); }, [view, activeSheet, dataset]); const tileRowCount = Math.ceil(records.length / tileColumns); const tileWindow = useVirtualRows(tileRowCount, TILE_ROW_HEIGHT, 2); // Zwei Refs auf demselben Knoten: `tileScrollRef` misst die Breite fuer die // Spaltenzahl, `tileWindow.containerRef` haengt den Scroll-Listener der // Fenster-Berechnung an. const setTileScrollNode = useCallback((node: HTMLDivElement | null) => { tileScrollRef.current = node; tileWindow.containerRef(node); if (node && node.clientWidth > 0) { const contentWidth = node.clientWidth - TILE_GRID_INSET * 2; setTileColumns(Math.max(1, Math.floor((contentWidth + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)))); } }, [tileWindow.containerRef]); // Datensaetze: die aktive Karte kann ausserhalb des gerenderten Fensters // liegen (Weiter/Zurueck im Leser, oder Einstieg aus den Kacheln auf einen // weit unten stehenden Fund) - dann muss die Liste selbst dorthin scrollen, // die einzelne Karte kann das nicht mehr selbst tun, wenn sie noch gar // nicht gemountet ist. const recordWindow = useVirtualRows(records.length, RECORD_ROW_HEIGHT, 8); useEffect(() => { if (view !== 'records') return; const index = records.findIndex(record => record.id === selectedRecordId); if (index >= 0) recordWindow.scrollToIndex(index); // eslint-disable-next-line react-hooks/exhaustive-deps }, [view, selectedRecordId]); useEffect(() => { const updateViewportSize = () => setViewportSize({ width: window.innerWidth, height: window.innerHeight }); window.addEventListener('resize', updateViewportSize); return () => window.removeEventListener('resize', updateViewportSize); }, []); useLayoutEffect(() => { const anchor = parkingAnchorRef.current; if (!anchor) return; const measure = () => setWorkSurfaceTop(anchor.getBoundingClientRect().top); measure(); const observer = new ResizeObserver(measure); observer.observe(anchor.parentElement ?? anchor); window.addEventListener('resize', measure); return () => { observer.disconnect(); window.removeEventListener('resize', measure); }; }, [activeSheet, dataset, view]); useEffect(() => { if (view === 'records') { setClusterPos(null); } }, [view]); useEffect(() => { if (view !== 'records') return; setResultWindows(current => restoreResultWindow(current, selectedRecordId)); }, [view, selectedRecordId]); /* Die Filterzeile steht in der Shell und ist beim ersten Effekt bereits eingehaengt - der Viewer erscheint nur gemeinsam mit ihr. */ useEffect(() => setDockSlot(document.getElementById(RESULT_DOCK_SLOT_ID)), []); /* Ein leerer Verbund bleibt nicht eingeklappt: sonst haette das naechste geoeffnete Result keine sichtbare Ablage. */ useEffect(() => { if (!clusteredWindows.length) setClusterDocked(false); }, [clusteredWindows.length]); if (!dataset || !activeSheet) return ; const recordTitle = (record: FindingRecord) => { const candidate = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title; const recordToolId = toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId); if (recordToolId === 'maester') return maesterFindingTitle(record, candidate); // CIS headings name their benchmark section instead of the rule number, which stays in Source Rule ID. return recordToolId === 'cis-cat' ? cisSectionHeading(record, cisBenchmarkRuleId(record, recordToolId), candidate) : candidate; }; const selected = records.find(record => record.id === selectedRecordId) ?? records[0]; const toolId = activeSheet.toolId ?? dataset.profileId; const linksFor = (record: FindingRecord) => recordResultSheets(record, dataset.sheets, activeSheet.id, toolId); const messageFor = (record: FindingRecord) => resultMessageFor(record, activeSheet.toolId ?? dataset.profileId); const sectionsFor = (record: FindingRecord) => resultSectionsFor(record, activeSheet.toolId ?? dataset.profileId); const hasResults = (record: FindingRecord) => measureNavigationStep('result-links', () => linksFor(record).length > 0 || sectionsFor(record).length > 0); const activateWindow = (recordId: string) => setResultWindows(current => current.map(item => item.recordId === recordId ? { ...item, zIndex: ++nextZIndex.current } : item)); const openResults = (record: FindingRecord) => { const sheets = linksFor(record); const sections = sectionsFor(record); const recordToolId = toolIdForRecord(record, toolId); if (!sheets.length && !sections.length) return; // Wer ein Result anfasst, will es sehen: der eingeklappte Verbund geht auf. setClusterDocked(false); const total = sheets.reduce((sum, sheet) => sum + sheet.analysis.records.length, 0); // Der gesamte Zustandsuebergang laeuft im Updater, nicht ueber `resultWindows` // aus dem Render: `TileCard` ist memoisiert und vergleicht `onResults` nur auf // Vorhandensein (siehe TileCard.tsx), haelt also dauerhaft die Closure des // ersten Renders. Ein Lesen des Zustands hier waere daher immer die leere // Anfangsliste - dadurch bekam jedes Fenster dieselbe erste Farbe und ein // erneuter Klick schloss das Fenster nicht mehr. setResultWindows(current => { if (current.some(item => item.recordId === record.id)) { return current.filter(item => item.recordId !== record.id); } const usedColors = new Set(current.map(item => item.color)); const assignedColor = resultColors.find(candidate => !usedColors.has(candidate)) ?? resultColors[current.length % resultColors.length]; // Im Cluster bleibt genau ein Fenster maximiert. Bereits frei // herausgezogene Fenster werden dabei nicht veraendert und koennen // gleichzeitig maximiert bleiben. return [ ...prepareForNewClusterWindow(current), { recordId: record.id, toolId: recordToolId, title: sheets.length ? `Results (${total.toLocaleString('en-US')}) \u2013 ${recordTitle(record)}` : `Result \u2013 ${recordTitle(record)}`, sheets, sections, color: assignedColor, zIndex: ++nextZIndex.current, minimized: false, detached: false } ]; }); }; const resultColor = (recordId: string) => resultColorsByRecord.get(recordId); const toggleMinimize = (recordId: string) => { if (resultWindows.some(window => window.recordId === recordId && !window.minimized)) setClusterPos(defaultClusterPos); setResultWindows(current => toggleResultWindowMinimized(current, recordId)); }; const toggleBookmark = (recordId: string) => { const isBookmarked = Boolean(reviews[recordId]?.include); updateReview(recordId, { include: !isBookmarked }); }; const bookmarkCluster = () => { const allBookmarked = minimizedWindows.length > 0 && minimizedWindows.every(w => Boolean(reviews[w.recordId]?.include)); minimizedWindows.forEach(w => { updateReview(w.recordId, { include: !allBookmarked }); }); }; /** * Springt vom Ergebnisfenster zum Datensatz, dessen Beleg es zeigt. Ist die * Report-Ansicht bereits offen, fuehrt dieselbe Aktion zurueck in die Matrix. * Der Ansichtswechsel stellt das zugehoerige Cluster-Fenster her. Frei * herausgezogene Fenster bleiben maximiert und an ihrer Position. */ const openRecord = (recordId: string) => { setSelectedRecord(recordId); setResultWindows(current => restoreResultWindow(current, recordId)); setView('records'); }; // Berechnete Geometrie der Virtualisierung: als benannte Werte statt eines // literalen Inline-Style-Objekts, damit die Darstellung sonst in // src/styles bleibt (siehe FloatingResultsWindow.tsx fuer denselben // Kompromiss bei einer anderen laufzeitberechneten Position). const tileGridStyle = { gridTemplateColumns: `repeat(${tileColumns}, var(--tile-content-width))` }; const tileTopSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingTop }; const tileBottomSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingBottom }; const recordTopSpacerStyle = { height: recordWindow.paddingTop }; const recordBottomSpacerStyle = { height: recordWindow.paddingBottom }; // Die Fensterfarbe stammt aus einer Laufzeitpalette und kann deshalb nicht // in src/styles stehen - wie in FloatingResultsWindow wird nur der Farbwert // als benannte Eigenschaft durchgereicht, die Darstellung bleibt im CSS. const dockMarkStyle = (color: string) => ({ '--result-color': color } as unknown as CSSProperties); return ( <>