576 lines
30 KiB
TypeScript
576 lines
30 KiB
TypeScript
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 { 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 {
|
|
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 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<void>;
|
|
setImportEntrySelected(entryId: string, selected: boolean): void;
|
|
confirmImportEntry(entryId: string): void;
|
|
commitImportBatch(): Promise<void>;
|
|
closeImportWorkflow(): void;
|
|
setActiveSheet(sheetId: string): void;
|
|
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[];
|
|
workScopeId: string;
|
|
setTargetProjectId: (projectId?: string) => void;
|
|
openProject(projectId: string, scanRunIds?: string[]): Promise<boolean>;
|
|
setSelectedRecord(recordId: string): void;
|
|
updateReview(recordId: string, patch: Partial<Review>): void;
|
|
snapshot(): ReturnType<typeof createSnapshot> | null;
|
|
setTheme(mode: ThemeMode): void;
|
|
setDelimiter(delimiter: string): void;
|
|
toggleSidebar(): void;
|
|
setSidebarCollapsed(collapsed: boolean): void;
|
|
resetFilters(): void;
|
|
toggleWorkspace(): void;
|
|
}
|
|
|
|
const AppContext = createContext<AppContextValue | null>(null);
|
|
|
|
const mixedProfileCache = new WeakMap<ImportedDataset, ImportProfile>();
|
|
|
|
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'));
|
|
if (toolProfiles.length < 2) 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 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<ImportWorkflowState>({ phase: 'idle' });
|
|
const [targetProjectId, setTargetProjectId] = useState<string | undefined>();
|
|
const [activeScanRunIds, setActiveScanRunIds] = useState<string[] | undefined>();
|
|
const [workScopeId, setWorkScopeId] = useState('');
|
|
const openProjectRequest = useRef(0);
|
|
const [state, setState] = useState<AppState>(() => {
|
|
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(() => {
|
|
let active = true;
|
|
const checkBackend = async () => {
|
|
try {
|
|
const health = await assessorApi.health();
|
|
if (active) setState(current => ({ ...current, backendStatus: health.status === 'ok' ? 'connected' : 'unavailable' }));
|
|
} catch {
|
|
if (active) setState(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;
|
|
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,
|
|
projectId, titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn,
|
|
sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [],
|
|
statusFilter: current.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<string, string>();
|
|
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 };
|
|
|
|
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 = persistedProjectId
|
|
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
|
|
: undefined;
|
|
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
|
const reviews = await reviewRepository.load(projectId);
|
|
const activeSheet = workDataset.sheets[0];
|
|
const activeProfile = profileFor(workDataset);
|
|
const defaults = activeProfile.defaults(activeSheet);
|
|
setState(current => ({
|
|
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false,
|
|
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
|
|
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
|
statusFilter: current.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);
|
|
setImportWorkflow(current => enterImportPreview(current, { version: 1, batchId: `failed-${Date.now()}`, createdAt: new Date().toISOString(), entries: [] }, message));
|
|
setState(current => ({ ...current, status: `Import check failed: ${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 = persistedProjectId
|
|
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
|
|
: undefined;
|
|
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
|
const reviews = await reviewRepository.load(projectId);
|
|
const activeSheet = workDataset.sheets[0];
|
|
const activeProfile = profileFor(workDataset);
|
|
const defaults = activeProfile.defaults(activeSheet);
|
|
setState(current => ({
|
|
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false,
|
|
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
|
|
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
|
statusFilter: current.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[]): Promise<boolean> => {
|
|
const requestId = ++openProjectRequest.current;
|
|
const scopeId = createWorkScopeId(projectId, scanRunIds);
|
|
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);
|
|
setState(current => ({
|
|
...current,
|
|
projectId,
|
|
status: 'Loading saved client …'
|
|
}));
|
|
try {
|
|
const { models, dataset } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds);
|
|
if (requestId !== openProjectRequest.current) return false;
|
|
if (!models.length) {
|
|
setState(current => ({ ...current, status: 'This client does not have a scan report yet.' }));
|
|
return false;
|
|
}
|
|
if (!dataset) {
|
|
setState(current => ({ ...current, status: 'The saved scan reports do not contain any displayable findings.' }));
|
|
return false;
|
|
}
|
|
const reviews = await reviewRepository.load(scopeId);
|
|
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,
|
|
projectId,
|
|
search: '',
|
|
actionOnly: false,
|
|
view: 'columns',
|
|
selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '',
|
|
titleColumn: defaults.titleColumn,
|
|
sortColumn: defaults.sortColumn,
|
|
sortDirection: defaults.sortDirection,
|
|
statusFilter: 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'
|
|
}));
|
|
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<Review>) => {
|
|
setState(current => {
|
|
const { reviews, actionOnly } = updateReviewState(current, recordId, patch);
|
|
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<AppContextValue>(() => ({
|
|
...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: 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,
|
|
workScopeId,
|
|
setTargetProjectId: projectId => {
|
|
setTargetProjectId(projectId);
|
|
if (!projectId) setActiveScanRunIds(undefined);
|
|
},
|
|
openProject,
|
|
setSelectedRecord: selectedRecordId => setState(current => ({ ...current, selectedRecordId })),
|
|
updateReview,
|
|
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);
|
|
// 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: defaults.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: defaults.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, activeScanRunIds, activeSheet, closeImportWorkflow, commitImportBatch, confirmImportEntry, importFiles, importWorkflow, setImportEntrySelected, setTheme, state, updateReview, targetProjectId, workScopeId, openProject]);
|
|
|
|
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
|
}
|
|
|
|
export function useApp(): AppContextValue {
|
|
const context = useContext(AppContext);
|
|
if (!context) throw new Error('useApp must be used within AppProvider.');
|
|
return context;
|
|
}
|