import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react'; import { AssessorApiClient } from '../api/assessor-api-client'; import type { DatasetSheet, ImportedDataset } from '../domain/dataset'; import type { FindingReview } from '../domain/canonical-model'; import type { RiskAcceptanceCommand } from '../domain/risk-acceptance'; import type { ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance'; import { defaultReview } from '../domain/review'; import type { ImportProfile } from '../domain/import-profile'; import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow'; import type { Review } from '../domain/review'; import { importFile as importFileLocally } from '../importers/importer'; import { BrowserStorageAdapter } from '../platform/browser/browser-storage-adapter'; import { LocalStorageReviewRepository } from '../persistence/review-store'; import { createSnapshot, parseSnapshot } from '../persistence/snapshot-store'; import { genericProfile } from '../profiles/generic/generic-profile'; import { importProfiles } from '../profiles/profile-catalog'; import { stableProjectId } from '../shared/identifiers'; import { combineDatasets } from '../features/project-import/project-import'; import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace'; import { takeWorkspaceLoad, workspaceScopeKey } from '../features/project-import/workspace-load-handoff'; import { confirmEntryMappings, createBrowserFingerprintStore, createImportReport, enterImportPreview, failedPreviewEntry, LAST_IMPORT_REPORT_STORAGE_KEY, preparePreviewEntriesForReview, previewEntryFromDataset, recognitionOverridesFromResult, sha256File } from '../features/import-workflow/import-workflow'; import { updateReviewState } from '../features/review-workspace/update-review-state'; import { hydrateWorkspaceReviews, recordsByOccurrence, reviewFromDatabase, reviewsEqual, reviewToDatabase, reviewTargetKey } from '../features/review-workspace/database-review'; import type { DynamicFacetFilter } from '../features/search-filter/dynamic-facets'; import { filterAndSortRecords } from '../features/search-filter/filter-records'; import { applySavedViewState, initialAppState, type AppState, type SavedViewState, type ThemeMode, type ViewMode } from './app-state'; import { createWorkScopeId } from './work-scope'; const profiles: ImportProfile[] = importProfiles; const reviewRepository = new LocalStorageReviewRepository(new BrowserStorageAdapter()); const assessorApi = new AssessorApiClient(); interface AppContextValue extends AppState { activeSheet?: DatasetSheet; activeProfile: ImportProfile; importWorkflow: ImportWorkflowState; importFiles(files: FileList | File[], targetProjectId?: string, targetAssessmentId?: string, replacesScanRunId?: string): Promise; setImportEntrySelected(entryId: string, selected: boolean): void; confirmImportEntry(entryId: string): void; commitImportBatch(): Promise; closeImportWorkflow(): void; setActiveSheet(sheetId: string): void; recordRiskAcceptance(recordId: string, command: RiskAcceptanceCommand): Promise; recordScopedRiskAcceptance(write: ScopedRiskAcceptanceWrite): Promise; setView(view: ViewMode): void; setSearch(search: string): void; setSortColumn(column: string): void; toggleSortDirection(): void; setTitleColumn(column: string): void; setStatusFilter(filter: string): void; setFacetFilter(facetId: string, filter: DynamicFacetFilter | null): void; setActionOnly: (value: boolean) => void; setPathFindingIds(recordIds: string[]): void; applyViewBookmark(view: SavedViewState): void; targetProjectId?: string; activeScanRunIds?: string[]; activeAssessmentIds?: string[]; workScopeId: string; setTargetProjectId: (projectId?: string) => void; openProject(projectId: string, scanRunIds?: string[], assessmentIds?: string[], clientName?: string): Promise; setSelectedRecord(recordId: string): void; updateReview(recordId: string, patch: Partial): void; snapshot(): ReturnType | null; setTheme(mode: ThemeMode): void; setDelimiter(delimiter: string): void; toggleSidebar(): void; setSidebarCollapsed(collapsed: boolean): void; resetFilters(): void; toggleWorkspace(): void; } const AppContext = createContext(null); const mixedProfileCache = new WeakMap(); function mixedProfileFor(dataset: ImportedDataset): ImportProfile { const cached = mixedProfileCache.get(dataset); if (cached) return cached; const toolProfiles = [...new Set(dataset.sheets.map(sheet => sheet.toolId).filter((id): id is string => Boolean(id)))] .map(id => profiles.find(profile => profile.id === id)) .filter((profile): profile is ImportProfile => Boolean(profile && profile.id !== 'generic')); const hasPolicyResults = dataset.sheets.some(sheet => sheet.toolId === 'group-policy'); if (toolProfiles.length < 2 && !hasPolicyResults) return toolProfiles[0] ?? genericProfile; const statusProfiles = [genericProfile, ...toolProfiles]; const statusFilters = [...new Map(statusProfiles.flatMap(profile => profile.presentation.statusFilters) .map(filter => [filter.value, filter])).values()]; const mixed: ImportProfile = { ...genericProfile, id: 'multiple', name: 'Multiple Tools', version: toolProfiles.map(profile => `${profile.id}@${profile.version}`).sort().join('+'), matchesStatus: (record, status) => statusProfiles .filter(profile => profile.presentation.statusFilters.some(filter => filter.value === status)) .some(profile => profile.matchesStatus(record, status)), presentation: { ...genericProfile.presentation, statusFilters } }; mixedProfileCache.set(dataset, mixed); return mixed; } function profileFor(dataset: ImportedDataset | null): ImportProfile { if (!dataset) return genericProfile; if (dataset.profileId === 'generic' && new Set(dataset.sheets.map(sheet => sheet.toolId).filter(Boolean)).size > 1) { return mixedProfileFor(dataset); } return profiles.find(profile => profile.id === dataset.profileId) ?? genericProfile; } export function profileForSheet(dataset: ImportedDataset | null, sheet?: DatasetSheet): ImportProfile { return sheet?.toolId ? profiles.find(profile => profile.id === sheet.toolId) ?? genericProfile : profileFor(dataset); } function initialSheetStatus(_sheet: DatasetSheet | undefined, _defaultStatus: string): string { return 'deviation'; } function effectiveTheme(mode: ThemeMode): 'dark' | 'light' { if (mode !== 'system') return mode; return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; } export function AppProvider({ children }: PropsWithChildren) { const [importWorkflow, setImportWorkflow] = useState({ phase: 'idle' }); const [targetProjectId, setTargetProjectId] = useState(); const [activeScanRunIds, setActiveScanRunIds] = useState(); const [activeAssessmentIds, setActiveAssessmentIds] = useState(); const [workScopeId, setWorkScopeId] = useState(''); const openProjectRequest = useRef(0); const openProjectIdentity = useRef<{ id: string; name: string } | undefined>(undefined); const persistedReviews = useRef(new Map()); const reviewSaveQueues = useRef(new Map>()); const [state, setState] = useState(() => { const stored = (localStorage.getItem('ASSESSOR.theme') as ThemeMode | null) ?? (localStorage.getItem('exxTableReader.theme') as ThemeMode | null) ?? 'dark'; localStorage.setItem('ASSESSOR.theme', stored); document.documentElement.dataset.theme = effectiveTheme(stored); return { ...initialAppState, theme: stored }; }); useEffect(() => { if (!workScopeId) return; const persist = () => { void reviewRepository.save(workScopeId, state.reviews); }; if ('requestIdleCallback' in window) { const idleId = window.requestIdleCallback(persist, { timeout: 750 }); return () => { window.cancelIdleCallback(idleId); persist(); }; } const timeoutId = globalThis.setTimeout(persist, 150); return () => { globalThis.clearTimeout(timeoutId); persist(); }; }, [state.reviews, workScopeId]); useEffect(() => { if (!state.dataset || !state.projectId || state.backendStatus !== 'connected') return; const timer = globalThis.setTimeout(() => { for (const [occurrenceId, recordIds] of recordsByOccurrence(state.dataset!)) { const desired = recordIds.map(recordId => state.reviews[recordId]).find(Boolean); if (!desired) continue; const key = `occurrence:${occurrenceId}`; const stored = persistedReviews.current.get(key); if (stored && reviewsEqual(desired, reviewFromDatabase(stored))) continue; const previous = reviewSaveQueues.current.get(key) ?? Promise.resolve(); const queued = previous.catch(() => undefined).then(async () => { const current = persistedReviews.current.get(key); if (current && reviewsEqual(desired, reviewFromDatabase(current))) return; const review = reviewToDatabase({ ...desired, riskAcceptance: current?.riskAcceptance }, { kind: 'occurrence', occurrenceId }, current, new Date().toISOString()); await assessorApi.saveReview(review, current?.revision ?? 0); persistedReviews.current.set(reviewTargetKey(review.target), review); }).catch(() => { setState(current => current.projectId === state.projectId ? { ...current, status: 'Review could not be saved to the database. The local copy was kept.' } : current); }); reviewSaveQueues.current.set(key, queued); } }, 350); return () => globalThis.clearTimeout(timer); }, [state.backendStatus, state.dataset, state.projectId, state.reviews]); useEffect(() => { let active = true; const checkBackend = async () => { try { const health = await assessorApi.health(); if (active) setState(current => { const backendStatus = health.status === 'ok' ? 'connected' : 'unavailable'; return current.backendStatus === backendStatus ? current : { ...current, backendStatus }; }); } catch { if (active) setState(current => current.backendStatus === 'unavailable' ? current : { ...current, backendStatus: 'unavailable' }); } }; void checkBackend(); const intervalId = globalThis.setInterval(() => { void checkBackend(); }, 10_000); const handleFocus = () => { void checkBackend(); }; globalThis.addEventListener('focus', handleFocus); return () => { active = false; globalThis.clearInterval(intervalId); globalThis.removeEventListener('focus', handleFocus); }; }, []); const importFiles = useCallback(async (fileCollection: FileList | File[], targetProjectId?: string, targetAssessmentId?: string, replacesScanRunId?: string) => { const files = [...fileCollection]; if (!files.length) return; let preparedPreview: ImportBatchPreview | undefined; setImportWorkflow({ phase: 'preparing', targetProjectId, targetAssessmentId, replacesScanRunId }); setState(current => ({ ...current, status: `Checking ${files.length === 1 ? files[0].name : `${files.length} files`}...` })); try { if (replacesScanRunId && state.backendStatus !== 'connected') { throw new Error('A safe reimport requires the connected backend to verify the exact SHA-256 identity of the original scan. No data was imported.'); } if (files.length === 1 && files[0].name.toLocaleLowerCase('en-US').endsWith('.json')) { const snapshot = parseSnapshot(await files[0].text()); if (snapshot) { const projectId = stableProjectId(snapshot.dataset.fileName, snapshot.dataset.fileSize, snapshot.dataset.lastModified); const profile = profileFor(snapshot.dataset); const activeSheet = snapshot.dataset.sheets[0]; const defaults = profile.defaults(activeSheet); setState(current => ({ ...current, dataset: snapshot.dataset, activeSheetId: activeSheet?.id ?? '', reviews: snapshot.reviews, scopedRiskAcceptances: [], projectId, titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [], statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter), selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', actionOnly: false, view: 'columns', status: 'Snapshot loaded' })); setWorkScopeId(projectId); setImportWorkflow({ phase: 'idle' }); await reviewRepository.save(projectId, snapshot.reviews); return; } } let preview: ImportBatchPreview; if (state.backendStatus === 'connected') { preview = await assessorApi.previewImportBatch(files, state.delimiter, targetProjectId, targetAssessmentId, replacesScanRunId); if (replacesScanRunId && preview.replacesScanRunId !== replacesScanRunId) { throw new Error('The backend does not support the current safe reimport contract. Restart the local app and backend before retrying.'); } } else { const createdAt = new Date().toISOString(); const batchId = `batch-${Date.now()}-${Math.random().toString(16).slice(2)}`; const hashes = await Promise.all(files.map(sha256File)); const known = new Map(createBrowserFingerprintStore(localStorage).load().map(item => [item.sha256, item])); const firstInBatch = new Map(); const entries = []; for (let index = 0; index < files.length; index += 1) { const file = files[index]; const sha256 = hashes[index]; const fingerprint = { sha256, fileName: file.name, sizeBytes: file.size, lastModified: file.lastModified }; const duplicateOf = firstInBatch.get(sha256) ?? known.get(sha256)?.fileName; try { const dataset = await importFileLocally(file, { profiles, delimiter: state.delimiter }); entries.push({ ...previewEntryFromDataset(dataset, fingerprint, duplicateOf), id: `${batchId}:${index}` }); if (!firstInBatch.has(sha256)) firstInBatch.set(sha256, file.name); } catch (error) { entries.push({ ...failedPreviewEntry(fingerprint, error instanceof Error ? error.message : 'Unknown parser error'), id: `${batchId}:${index}` }); } } preview = { version: 1, batchId, createdAt, entries }; } // Im expliziten Reimport ist ein Byte-Treffer die erwartete Ersatzquelle // und bleibt daher ausgewaehlt. Beim normalen Einzelimport gilt weiterhin // die bestehende explizite Nutzerentscheidung fuer genau diese Datei. const entriesToProcess = preparePreviewEntriesForReview(preview.entries, { reimport: Boolean(replacesScanRunId), singleFile: files.length === 1 }); const reviewPreview: ImportBatchPreview = { ...preview, entries: entriesToProcess }; preparedPreview = reviewPreview; const selected = entriesToProcess.filter(entry => entry.selected && entry.dataset && entry.status !== 'error'); const hasErrors = entriesToProcess.some(entry => entry.status === 'error'); // Ein bereits vorhandener Bericht (gleiche Bytes oder derselbe Bericht in // einem anderen Ausgabeformat) darf nie still ein zweites Mal aufgenommen // werden - hier muss der Prüfdialog mit dem Hinweis erscheinen. const hasKnownReport = entriesToProcess.some(entry => entry.duplicateOf || entry.possibleDuplicateOf); if (selected.length > 0 && !hasErrors && !hasKnownReport && selected.every(entry => entry.mappingsToConfirm === 0)) { const imported = selected.map(entry => entry.dataset!); const knownProfiles = [...new Set(imported.map(dataset => dataset.profileId).filter(id => id !== 'generic'))]; const profile = knownProfiles.length === 1 ? profiles.find(candidate => candidate.id === knownProfiles[0]) ?? genericProfile : genericProfile; const dataset = imported.length === 1 ? imported[0] : combineDatasets(imported, profile); const selections = selected.map(entry => ({ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified, recognitionOverrides: recognitionOverridesFromResult(entry.dataset?.recognition, entry.dataset?.mapping) })); let persistedProjectId: string | undefined; if (state.backendStatus === 'connected') { if (replacesScanRunId && preview.replacesScanRunId !== replacesScanRunId) { throw new Error('The reimport preview is not bound to the selected scan. Restart the local app and backend before retrying.'); } const completion = await assessorApi.completeImportBatch(preview.batchId, selections, targetProjectId, targetAssessmentId, replacesScanRunId); if (replacesScanRunId && completion.replacedScanRunId !== replacesScanRunId) { throw new Error('The backend did not confirm the selected replacement target.'); } persistedProjectId = completion.projectId; } else { createBrowserFingerprintStore(localStorage).record(selections.map(({ entryId: _entryId, recognitionOverrides: _overrides, ...fingerprint }) => fingerprint)); } const importedIds = new Set(selected.map(entry => entry.id)); const report = createImportReport(preview, importedIds); localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report)); const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified); const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId ? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])]) : [undefined, []]; const workDataset = persistedWorkspace?.dataset ?? dataset; const localReviews = await reviewRepository.load(projectId); const hydrated = persistedWorkspace ? hydrateWorkspaceReviews(workDataset, persistedWorkspace.models.flatMap(model => model.reviews), localReviews) : { reviews: localReviews, persisted: new Map() }; persistedReviews.current = hydrated.persisted; const reviews = hydrated.reviews; const activeSheet = workDataset.sheets[0]; const activeProfile = profileFor(workDataset); const defaults = activeProfile.defaults(activeSheet); setState(current => ({ ...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false, view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter), facetFilters: {}, pathFindingIds: [], status: `${report.importedFiles} file${report.importedFiles === 1 ? '' : 's'} imported · ${report.importedRecords.toLocaleString('en-US')} records` })); setWorkScopeId(projectId); setImportWorkflow({ phase: 'idle' }); globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } })); } else { setImportWorkflow(current => enterImportPreview(current, reviewPreview)); const usable = preview.entries.filter(entry => entry.dataset && entry.status !== 'duplicate').length; setState(current => ({ ...current, status: `${files.length} file${files.length === 1 ? '' : 's'} checked · ${usable} preselected` })); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(error); const failedBeforePreview = !preparedPreview; setImportWorkflow(current => enterImportPreview( current, preparedPreview ?? { version: 1, batchId: `failed-${Date.now()}`, createdAt: new Date().toISOString(), entries: [] }, failedBeforePreview ? message : `Import not executed: ${message}` )); setState(current => ({ ...current, status: failedBeforePreview ? `Import check failed: ${message}` : `Import not executed: ${message}` })); } }, [state.backendStatus, state.delimiter]); const setImportEntrySelected = useCallback((entryId: string, selected: boolean) => { setImportWorkflow(current => current.preview ? { ...current, preview: { ...current.preview, entries: current.preview.entries.map(entry => entry.id === entryId && entry.status !== 'error' && entry.dataset ? { ...entry, selected } : entry) } } : current); }, []); const confirmImportEntry = useCallback((entryId: string) => { setImportWorkflow(current => current.preview ? { ...current, preview: { ...current.preview, entries: current.preview.entries.map(entry => entry.id === entryId ? confirmEntryMappings(entry, profiles) : entry) } } : current); }, []); const closeImportWorkflow = useCallback(() => { setImportWorkflow({ phase: 'idle' }); }, []); const commitImportBatch = useCallback(async () => { const preview = importWorkflow.preview; const targetProjectId = importWorkflow.targetProjectId; const targetAssessmentId = importWorkflow.targetAssessmentId; const replacesScanRunId = importWorkflow.replacesScanRunId; if (!preview) return; const selected = preview.entries.filter(entry => entry.selected && entry.dataset && entry.status !== 'error'); if (!selected.length) { setImportWorkflow(current => ({ ...current, error: 'Select at least one successfully checked file.' })); return; } if (selected.some(entry => entry.mappingsToConfirm > 0)) { setImportWorkflow(current => ({ ...current, error: 'Confirm all suggested source, sheet, and field mappings first.' })); return; } setImportWorkflow(current => ({ ...current, phase: 'committing', error: undefined })); try { const imported = selected.map(entry => entry.dataset!); const knownProfiles = [...new Set(imported.map(dataset => dataset.profileId).filter(id => id !== 'generic'))]; const profile = knownProfiles.length === 1 ? profiles.find(candidate => candidate.id === knownProfiles[0]) ?? genericProfile : genericProfile; const dataset = imported.length === 1 ? imported[0] : combineDatasets(imported, profile); const selections = selected.map(entry => ({ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified, recognitionOverrides: recognitionOverridesFromResult(entry.dataset?.recognition, entry.dataset?.mapping) })); let persistedProjectId: string | undefined; if (state.backendStatus === 'connected') { if (replacesScanRunId && preview.replacesScanRunId !== replacesScanRunId) { throw new Error('The reimport preview is not bound to the selected scan. Restart the local app and backend before retrying.'); } const completion = await assessorApi.completeImportBatch(preview.batchId, selections, targetProjectId, targetAssessmentId, replacesScanRunId); if (replacesScanRunId && completion.replacedScanRunId !== replacesScanRunId) { throw new Error('The backend did not confirm the selected replacement target.'); } persistedProjectId = completion.projectId; } else { createBrowserFingerprintStore(localStorage).record(selections.map(({ entryId: _entryId, recognitionOverrides: _overrides, ...fingerprint }) => fingerprint)); } const importedIds = new Set(selected.map(entry => entry.id)); const report = createImportReport(preview, importedIds); localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report)); const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified); const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId ? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])]) : [undefined, []]; const workDataset = persistedWorkspace?.dataset ?? dataset; const localReviews = await reviewRepository.load(projectId); const hydrated = persistedWorkspace ? hydrateWorkspaceReviews(workDataset, persistedWorkspace.models.flatMap(model => model.reviews), localReviews) : { reviews: localReviews, persisted: new Map() }; persistedReviews.current = hydrated.persisted; const reviews = hydrated.reviews; const activeSheet = workDataset.sheets[0]; const activeProfile = profileFor(workDataset); const defaults = activeProfile.defaults(activeSheet); setState(current => ({ ...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false, view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter), facetFilters: {}, pathFindingIds: [], status: `${report.importedFiles} file${report.importedFiles === 1 ? '' : 's'} imported · ${report.importedRecords.toLocaleString('en-US')} records` })); setWorkScopeId(projectId); setImportWorkflow({ phase: 'report', preview, report }); globalThis.dispatchEvent(new CustomEvent('assessor:datahub-changed', { detail: { projectId } })); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(error); setImportWorkflow(current => ({ ...current, phase: 'preview', error: `Import not executed: ${message}` })); setState(current => ({ ...current, status: `Import not executed: ${message}` })); } }, [importWorkflow.preview, state.backendStatus]); const openProject = useCallback(async (projectId: string, scanRunIds?: string[], selectedAssessmentIds?: string[], clientName?: string): Promise => { const requestId = ++openProjectRequest.current; const scopeId = createWorkScopeId(projectId, scanRunIds, selectedAssessmentIds); const loadingClientName = clientName?.trim() || (openProjectIdentity.current?.id === projectId ? openProjectIdentity.current.name : '') || projectId; openProjectIdentity.current = { id: projectId, name: loadingClientName }; setTargetProjectId(projectId); // Der fachliche Scope wechselt atomar mit dem Client-/Timeslot-Auftrag. // Result DB und Graph duerfen waehrend des asynchronen Workspace-Ladens // weder den vorherigen Timeslot noch den gesamten neuen Client anzeigen. setActiveScanRunIds(scanRunIds?.length ? [...new Set(scanRunIds)] : undefined); setActiveAssessmentIds(selectedAssessmentIds); setState(current => ({ ...current, dataset: null, activeSheetId: '', projectId, status: `Loading Client: ${loadingClientName}` })); try { const [{ models, dataset, assessmentIds }, scopedRiskAcceptances] = await Promise.all([ takeWorkspaceLoad(workspaceScopeKey(projectId, scanRunIds, selectedAssessmentIds)) ?? loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds), assessorApi.listScopedRiskAcceptances(projectId).catch(() => []) ]); if (requestId !== openProjectRequest.current) return false; if (models[0]?.project.name) openProjectIdentity.current = { id: projectId, name: models[0].project.name }; if (!models.length) { persistedReviews.current = new Map(); setState(current => ({ ...current, scopedRiskAcceptances, status: 'This client does not have a scan report yet.' })); return false; } if (!dataset) { persistedReviews.current = new Map(models.flatMap(model => model.reviews).map(review => [reviewTargetKey(review.target), review])); setActiveAssessmentIds(assessmentIds); setWorkScopeId(scopeId); setState(current => ({ ...current, reviews: {}, scopedRiskAcceptances, selectedRecordId: '', status: 'Client opened. No displayable results are available in the selected timeslot yet.' })); return true; } const localReviews = await reviewRepository.load(scopeId); const hydrated = hydrateWorkspaceReviews(dataset, models.flatMap(model => model.reviews), localReviews); persistedReviews.current = hydrated.persisted; const reviews = hydrated.reviews; if (requestId !== openProjectRequest.current) return false; const activeSheet = dataset.sheets[0]; const profile = profileFor(dataset); const defaults = profile.defaults(activeSheet); setState(current => ({ ...current, dataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false, view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter), facetFilters: {}, pathFindingIds: [], status: models[0].project.name + ' · ' + dataset.scanTool + ' · ' + models.length.toLocaleString('en-US') + ' scan report' + (models.length === 1 ? '' : 's') + ' · ' + activeSheet.analysis.records.length.toLocaleString('en-US') + ' records loaded' })); setActiveAssessmentIds(assessmentIds); setWorkScopeId(scopeId); return true; } catch (reason) { if (requestId !== openProjectRequest.current) return false; const message = reason instanceof Error ? reason.message : 'Unknown loading error'; setState(current => ({ ...current, dataset: null, activeSheetId: '', status: 'Client could not be loaded: ' + message })); return false; } }, []); const updateReview = useCallback((recordId: string, patch: Partial) => { setState(current => { const selected = current.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === recordId); const linkedRecordIds = selected?.findingOccurrenceId && current.dataset ? recordsByOccurrence(current.dataset).get(selected.findingOccurrenceId) ?? [recordId] : [recordId]; let next = { reviews: current.reviews, actionOnly: current.actionOnly }; for (const linkedRecordId of linkedRecordIds) next = updateReviewState(next, linkedRecordId, patch); const { reviews, actionOnly } = next; return { ...current, reviews, actionOnly }; }); }, []); const setTheme = useCallback((theme: ThemeMode) => { localStorage.setItem('ASSESSOR.theme', theme); document.documentElement.dataset.theme = effectiveTheme(theme); setState(current => ({ ...current, theme })); }, []); const activeSheet = state.dataset?.sheets.find(sheet => sheet.id === state.activeSheetId) ?? state.dataset?.sheets[0]; const activeProfile = profileForSheet(state.dataset, activeSheet); const value = useMemo(() => ({ ...state, activeSheet, activeProfile, importWorkflow, importFiles, setImportEntrySelected, confirmImportEntry, commitImportBatch, closeImportWorkflow, setActiveSheet: activeSheetId => setState(current => { const sheet = current.dataset?.sheets.find(candidate => candidate.id === activeSheetId); const defaults = profileForSheet(current.dataset, sheet).defaults(sheet); return { ...current, activeSheetId, selectedRecordId: sheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, statusFilter: initialSheetStatus(sheet, defaults.statusFilter), facetFilters: {}, pathFindingIds: [] }; }), setView: view => setState(current => ({ ...current, view })), setSearch: search => setState(current => ({ ...current, search, selectedRecordId: '' })), setSortColumn: sortColumn => setState(current => ({ ...current, sortColumn })), toggleSortDirection: () => setState(current => ({ ...current, sortDirection: current.sortDirection === 'asc' ? 'desc' : 'asc' })), setTitleColumn: titleColumn => setState(current => ({ ...current, titleColumn })), setStatusFilter: statusFilter => setState(current => ({ ...current, statusFilter })), setFacetFilter: (facetId, filter) => setState(current => { const facetFilters = { ...current.facetFilters }; if (!filter || (!filter.values?.length && !filter.minimum && !filter.maximum)) delete facetFilters[facetId]; else facetFilters[facetId] = filter; return { ...current, facetFilters, selectedRecordId: '' }; }), setActionOnly: actionOnly => setState(current => ({ ...current, actionOnly })), setPathFindingIds: recordIds => setState(current => { const pathFindingIds = [...new Set(recordIds.filter(Boolean))]; const selectedRecordId = pathFindingIds.includes(current.selectedRecordId) ? current.selectedRecordId : (current.activeSheetId ? current.dataset?.sheets.find(sheet => sheet.id === current.activeSheetId)?.analysis.records.find(record => pathFindingIds.includes(record.id))?.id ?? '' : ''); return { ...current, pathFindingIds, selectedRecordId }; }), applyViewBookmark: savedView => setState(current => applySavedViewState(current, savedView)), targetProjectId, activeScanRunIds, activeAssessmentIds, workScopeId, setTargetProjectId: projectId => { setTargetProjectId(projectId); if (!projectId) { openProjectIdentity.current = undefined; setActiveScanRunIds(undefined); setActiveAssessmentIds(undefined); } }, openProject, setSelectedRecord: selectedRecordId => setState(current => ({ ...current, selectedRecordId })), updateReview, recordRiskAcceptance: async (recordId, command) => { const record = state.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(item => item.id === recordId); if (!targetProjectId || !record?.findingOccurrenceId || state.backendStatus !== 'connected') throw new Error('Connect to the backend and open a saved finding before recording risk acceptance.'); const occurrenceId = record.findingOccurrenceId; const key = `occurrence:${occurrenceId}`; const persisted = persistedReviews.current; const previous = reviewSaveQueues.current.get(key) ?? Promise.resolve(); const queued = previous.catch(() => undefined).then(async () => { let current = persisted.get(key); const desired = { ...defaultReview(), ...state.reviews[recordId], riskAcceptance: current?.riskAcceptance }; if (!current || !reviewsEqual(desired, reviewFromDatabase(current))) { const review = reviewToDatabase(desired, { kind: 'occurrence', occurrenceId }, current, new Date().toISOString()); const saved = await assessorApi.saveReview(review, current?.revision ?? 0); current = saved.review; persisted.set(key, current); } const saved = await assessorApi.recordRiskAcceptance(targetProjectId, occurrenceId, command, current.revision); persisted.set(key, saved.review); setState(latest => { if (latest.dataset !== state.dataset) return latest; const reviews = { ...latest.reviews }; for (const id of recordsByOccurrence(latest.dataset!).get(occurrenceId) ?? []) { reviews[id] = { ...defaultReview(), ...reviews[id], riskAcceptance: saved.review.riskAcceptance }; } return { ...latest, reviews }; }); }); reviewSaveQueues.current.set(key, queued); return queued; }, recordScopedRiskAcceptance: async write => { if (!targetProjectId || state.backendStatus !== 'connected') throw new Error('Connect to the backend and open a saved Defender scope before recording risk acceptance.'); const saved = await assessorApi.recordScopedRiskAcceptance(targetProjectId, write); setState(current => ({ ...current, scopedRiskAcceptances: [saved.acceptance, ...current.scopedRiskAcceptances.filter(item => item.id !== saved.acceptance.id)] })); }, snapshot: () => state.dataset ? createSnapshot(state.dataset, state.reviews) : null, setTheme, setDelimiter: delimiter => setState(current => ({ ...current, delimiter })), toggleSidebar: () => setState(current => ({ ...current, sidebarCollapsed: !current.sidebarCollapsed })), setSidebarCollapsed: sidebarCollapsed => setState(current => current.sidebarCollapsed === sidebarCollapsed ? current : { ...current, sidebarCollapsed }), resetFilters: () => setState(current => { const profile = profileForSheet(current.dataset, activeSheet); const defaults = profile.defaults(activeSheet); const statusFilter = initialSheetStatus(activeSheet, defaults.statusFilter); // Der gerade gelesene Fund bleibt ausgewaehlt, solange er im // Standardfilter vorkommt - sonst sprang die Ansicht auf den ersten // Datensatz und man verlor die Stelle, an der man gerade gearbeitet hat. // Die Ersatzwahl nutzt bewusst dieselbe Filterung und Sortierung wie die // Trefferliste, damit sie auf dem obersten sichtbaren Fund landet und // nicht auf dem ersten der unsortierten Blattreihenfolge. const visible = filterAndSortRecords(activeSheet, { activeProfile: profile, search: '', sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, statusFilter, facetFilters: {}, dataset: current.dataset, actionOnly: current.actionOnly, reviews: current.reviews }); const keepsSelection = visible.some(record => record.id === current.selectedRecordId); return { ...current, search: '', sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, titleColumn: defaults.titleColumn, statusFilter, facetFilters: {}, pathFindingIds: [], selectedRecordId: keepsSelection ? current.selectedRecordId : (visible[0]?.id ?? '') }; }), toggleWorkspace: () => setState(current => { if (!Object.values(current.reviews).some(review => review.include)) return current; return { ...current, actionOnly: !current.actionOnly, view: 'columns' }; }) }), [activeProfile, activeAssessmentIds, activeScanRunIds, activeSheet, closeImportWorkflow, commitImportBatch, confirmImportEntry, importFiles, importWorkflow, setImportEntrySelected, setTheme, state, updateReview, targetProjectId, workScopeId, openProject]); return {children}; } export function useApp(): AppContextValue { const context = useContext(AppContext); if (!context) throw new Error('useApp must be used within AppProvider.'); return context; } /** Retained views keep their last visible inputs without doing hidden derivation work. */ export function RetainedAppState({ active, children }: PropsWithChildren<{ active: boolean }>) { const current = useApp(); const lastVisible = useRef(current); if (active) lastVisible.current = current; return {children}; }