493 lines
25 KiB
TypeScript
493 lines
25 KiB
TypeScript
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<OpenResultWindow[]>([]);
|
|
const [clusterPos, setClusterPos] = useState<{ left: number; top: number } | null>(null);
|
|
const [clusterWidth, setClusterWidth] = useState<number>(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<HTMLElement | null>(null);
|
|
const [viewportSize, setViewportSize] = useState(() => ({
|
|
width: window.innerWidth,
|
|
height: window.innerHeight
|
|
}));
|
|
const parkingAnchorRef = useRef<HTMLSpanElement>(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<HTMLDivElement>(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 <EmptyWorkspace />;
|
|
|
|
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 (
|
|
<>
|
|
<span ref={parkingAnchorRef} className="results-parking-anchor" aria-hidden="true" />
|
|
{!records.length ? (
|
|
<div className="work-filter-empty" role="status">
|
|
<h3>No results match the current filters</h3>
|
|
<p>The selected sheet contains {activeSheet.analysis.records.length.toLocaleString('en-US')} saved results. GPO comparisons awaiting review or re-evaluation are available under Result Status: All.</p>
|
|
<button className="btn" type="button" onClick={() => {
|
|
const source = app.facetFilters['provenance:source-scan'];
|
|
app.resetFilters();
|
|
app.setStatusFilter('all');
|
|
app.setActionOnly(false);
|
|
app.setSearch('');
|
|
app.setPathFindingIds([]);
|
|
if (source) app.setFacetFilter('provenance:source-scan', source);
|
|
}}>Show all results for this source</button>
|
|
</div>
|
|
) : view === 'columns' ? (
|
|
<div className="viewer-scroll tile-view-scroll" ref={setTileScrollNode}><div className="tile-grid" style={tileGridStyle}>
|
|
{tileWindow.paddingTop > 0 && <div aria-hidden="true" style={tileTopSpacerStyle} />}
|
|
{records.slice(tileWindow.startIndex * tileColumns, tileWindow.endIndex * tileColumns).map(record => (
|
|
<TileCard key={record.id} record={record} title={recordTitle(record)} bookmarked={Boolean(reviews[record.id]?.include)} resultColor={resultColor(record.id)} toolId={activeSheet?.toolId ?? dataset?.profileId} search={search} onOpen={() => {
|
|
setSelectedRecord(record.id);
|
|
setView('records');
|
|
}} onBookmark={() => updateReview(record.id, { include: !reviews[record.id]?.include })} onResults={hasResults(record) ? () => openResults(record) : undefined} />
|
|
))}
|
|
{tileWindow.paddingBottom > 0 && <div aria-hidden="true" style={tileBottomSpacerStyle} />}
|
|
</div></div>
|
|
) : view === 'records' && selected ? (
|
|
<div className="records-layout">
|
|
<div className="record-list" ref={recordWindow.containerRef}>
|
|
{recordWindow.paddingTop > 0 && <div aria-hidden="true" style={recordTopSpacerStyle} />}
|
|
{records.slice(recordWindow.startIndex, recordWindow.endIndex).map(record => <FindingCard key={record.id} record={record} title={recordTitle(record)} bookmarked={Boolean(reviews[record.id]?.include)} selected={record.id === selected.id} toolId={activeSheet?.toolId ?? dataset?.profileId} search={search} onOpen={() => setSelectedRecord(record.id)} />)}
|
|
{recordWindow.paddingBottom > 0 && <div aria-hidden="true" style={recordBottomSpacerStyle} />}
|
|
</div>
|
|
<FindingReader
|
|
record={selected}
|
|
title={recordTitle(selected)}
|
|
review={reviews[selected.id]}
|
|
previousPolicyReviews={previousPolicyReviews(selected, reviews)}
|
|
linkedSheets={linksFor(selected)}
|
|
resultReferences={[...resultReferenceValues(selected), ...pingCastleSectionIds(selected)].filter(value => value.trim() !== (messageFor(selected) ?? '').trim())}
|
|
search={search}
|
|
position={records.indexOf(selected)}
|
|
recordCount={records.length}
|
|
toolId={activeSheet?.toolId ?? dataset?.profileId}
|
|
onPrevious={() => setSelectedRecord(records[Math.max(0, records.indexOf(selected) - 1)].id)}
|
|
onNext={() => setSelectedRecord(records[Math.min(records.length - 1, records.indexOf(selected) + 1)].id)}
|
|
onReview={patch => updateReview(selected.id, patch)}
|
|
onRiskAcceptance={selected.findingOccurrenceId && app.backendStatus === 'connected' ? command => app.recordRiskAcceptance(selected.id, command) : undefined}
|
|
onResults={hasResults(selected) ? () => openResults(selected) : undefined}
|
|
resultsOpen={Boolean(resultColor(selected.id))}
|
|
resultColor={resultColor(selected.id)}
|
|
/>
|
|
</div>
|
|
) : view === 'table' ? (
|
|
<DataTable key={activeSheet.id} sheet={activeSheet} records={records} search={search} />
|
|
) : (
|
|
<RawDataView records={records} raw={activeSheet.raw || dataset.rawText}
|
|
filtered={Boolean(search || app.actionOnly || app.statusFilter !== 'all' || Object.keys(app.facetFilters).length)} search={search}/>
|
|
)}
|
|
|
|
{clusterDocked && dockSlot && createPortal(
|
|
<div className="result-dock">
|
|
<button className="result-dock-toggle" type="button" onClick={() => setClusterDocked(false)} title="Results wieder aufklappen" aria-label="Results wieder aufklappen" aria-expanded="false"><ClusterSlotIcon /></button>
|
|
<span className="result-dock-count">{clusteredWindows.length}</span>
|
|
<span className="result-dock-marks">
|
|
{clusteredWindows.map(item => (
|
|
<button
|
|
key={item.recordId}
|
|
className="result-dock-mark"
|
|
type="button"
|
|
style={dockMarkStyle(item.color)}
|
|
title={item.title}
|
|
aria-label={item.title}
|
|
onClick={() => {
|
|
setClusterDocked(false);
|
|
setResultWindows(current => restoreResultWindow(current, item.recordId));
|
|
}}
|
|
>
|
|
<TriangleIcon direction="right" />
|
|
</button>
|
|
))}
|
|
</span>
|
|
</div>,
|
|
dockSlot
|
|
)}
|
|
|
|
{resultWindows.map((item, index) => {
|
|
// Eingeklappt verschwindet der Verbund aus der Arbeitsflaeche; frei
|
|
// herausgezogene Fenster bleiben stehen, sie gehoeren nicht dazu.
|
|
if (clusterDocked && !item.detached) return null;
|
|
const minimizedIndex = item.minimized ? minimizedWindows.findIndex(w => w.recordId === item.recordId) : -1;
|
|
// Das geoeffnete Fenster und die erste Minimierung teilen sich die
|
|
// Oberkante (beide Index 0); die erste Minimierung liegt also genau
|
|
// dahinter und zeigt nur ihren farbigen Reiter links daneben. Jede
|
|
// weitere Minimierung sitzt eine Kopfzeilenhoehe tiefer.
|
|
const effectiveClusterIndex = item.minimized ? minimizedIndex : 0;
|
|
// Die Verbund-Bedienelemente haengen immer an der ersten Minimierung,
|
|
// damit sie an der Cluster-Kante stehen bleiben, auch wenn das
|
|
// geoeffnete Fenster frei herausgezogen wurde. Sie erscheinen erst ab
|
|
// zwei Cluster-Fenstern; frei herausgezogene Fenster zaehlen nicht mit.
|
|
const isClusterTop = item.minimized && minimizedIndex === 0;
|
|
const isClusterBottom = item.minimized && minimizedIndex === minimizedWindows.length - 1;
|
|
const windowRecord = records.find(record => record.id === item.recordId);
|
|
|
|
return (
|
|
<FloatingResultsWindow
|
|
key={item.recordId}
|
|
id={`results-${item.recordId}`}
|
|
title={item.title}
|
|
sheets={item.sheets}
|
|
toolId={item.toolId}
|
|
outcome={windowRecord ? resultOutcomeFor(windowRecord, toolId) : undefined}
|
|
sections={item.sections}
|
|
color={item.color}
|
|
zIndex={item.zIndex}
|
|
offset={index * 24}
|
|
search={search}
|
|
onActivate={() => activateWindow(item.recordId)}
|
|
onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))}
|
|
onOpenRecord={() => openRecord(item.recordId)}
|
|
actionLabel="To Report"
|
|
actionTitle="Jump to this finding's record in the Report"
|
|
minimized={item.minimized}
|
|
onToggleMinimize={() => toggleMinimize(item.recordId)}
|
|
detached={item.detached}
|
|
onDetach={() => setResultWindows(current => detachResultWindow(current, item.recordId))}
|
|
clusterIndex={effectiveClusterIndex >= 0 ? effectiveClusterIndex : undefined}
|
|
isClusterBottom={isClusterBottom}
|
|
clusterCount={clusteredWindows.length}
|
|
clusterPosition={effectiveClusterPos}
|
|
clusterWidth={effectiveClusterWidth}
|
|
isClusterTop={isClusterTop}
|
|
onMoveCluster={(dx, dy) => setClusterPos(current => {
|
|
const cur = current ?? defaultClusterPos;
|
|
return {
|
|
left: Math.max(0, Math.min(cur.left + dx, maxClusterLeft)),
|
|
top: Math.max(minClusterTop, Math.min(cur.top + dy, maxClusterTop))
|
|
};
|
|
})}
|
|
onResizeClusterWidth={(newW, dxLeft) => {
|
|
setClusterWidth(newW);
|
|
if (dxLeft) {
|
|
setClusterPos(current => {
|
|
const position = current ?? defaultClusterPos;
|
|
return { ...position, left: Math.max(0, position.left + dxLeft) };
|
|
});
|
|
}
|
|
}}
|
|
bookmarked={Boolean(reviews[item.recordId]?.include)}
|
|
onToggleBookmark={() => toggleBookmark(item.recordId)}
|
|
allClusterBookmarked={minimizedWindows.length > 0 && minimizedWindows.every(w => Boolean(reviews[w.recordId]?.include))}
|
|
onBookmarkCluster={bookmarkCluster}
|
|
onCloseCluster={() => setResultWindows(current => current.filter(candidate => candidate.detached))}
|
|
onDockCluster={() => setClusterDocked(true)}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|