Extract the monolithic SDK context provider into seven focused modules: - context-types.ts (203 LOC): SDKContextValue interface, initialState, ExtendedSDKAction - context-reducer.ts (353 LOC): sdkReducer with all action handlers - context-provider.tsx (495 LOC): SDKProvider component + SDKContext - context-hooks.ts (17 LOC): useSDK hook - context-validators.ts (94 LOC): local checkpoint validation logic - context-projects.ts (67 LOC): project management API helpers - context-sync-helpers.ts (145 LOC): sync infrastructure init/cleanup/callbacks - context.tsx (23 LOC): barrel re-export preserving existing import paths All files under the 500-line hard cap. Build verified with `npx next build`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import React, { createContext, useReducer, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
import { useRouter, usePathname } from 'next/navigation'
|
|
import {
|
|
SDKState,
|
|
CheckpointStatus,
|
|
UseCaseAssessment,
|
|
Risk,
|
|
Control,
|
|
CustomerType,
|
|
CompanyProfile,
|
|
ImportedDocument,
|
|
GapAnalysis,
|
|
SDKPackageId,
|
|
ProjectInfo,
|
|
getStepById,
|
|
getStepByUrl,
|
|
getNextStep,
|
|
getPreviousStep,
|
|
getCompletionPercentage,
|
|
getPhaseCompletionPercentage,
|
|
getPackageCompletionPercentage,
|
|
} from './types'
|
|
import { exportToPDF, exportToZIP } from './export'
|
|
import { SDKApiClient, getSDKApiClient } from './api-client'
|
|
import { StateSyncManager, SyncState } from './sync'
|
|
import { generateDemoState } from './demo-data'
|
|
import { SDKContextValue, initialState, SDK_STORAGE_KEY } from './context-types'
|
|
import { sdkReducer } from './context-reducer'
|
|
import { validateCheckpointLocally } from './context-validators'
|
|
import {
|
|
ensureApiClient,
|
|
createProjectApi,
|
|
listProjectsApi,
|
|
archiveProjectApi,
|
|
restoreProjectApi,
|
|
permanentlyDeleteProjectApi,
|
|
} from './context-projects'
|
|
import {
|
|
buildSyncCallbacks,
|
|
loadInitialState,
|
|
initSyncInfra,
|
|
cleanupSyncInfra,
|
|
} from './context-sync-helpers'
|
|
|
|
export const SDKContext = createContext<SDKContextValue | null>(null)
|
|
|
|
interface SDKProviderProps {
|
|
children: React.ReactNode
|
|
tenantId?: string
|
|
userId?: string
|
|
projectId?: string
|
|
enableBackendSync?: boolean
|
|
}
|
|
|
|
export function SDKProvider({
|
|
children,
|
|
tenantId = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
|
userId = 'default',
|
|
projectId,
|
|
enableBackendSync = false,
|
|
}: SDKProviderProps) {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const [state, dispatch] = useReducer(sdkReducer, {
|
|
...initialState,
|
|
tenantId,
|
|
userId,
|
|
projectId: projectId || '',
|
|
})
|
|
const [isCommandBarOpen, setCommandBarOpen] = React.useState(false)
|
|
const [isInitialized, setIsInitialized] = React.useState(false)
|
|
const [syncState, setSyncState] = React.useState<SyncState>({
|
|
status: 'idle',
|
|
lastSyncedAt: null,
|
|
localVersion: 0,
|
|
serverVersion: 0,
|
|
pendingChanges: 0,
|
|
error: null,
|
|
})
|
|
const [isOnline, setIsOnline] = React.useState(true)
|
|
|
|
// Refs for sync manager and API client
|
|
const apiClientRef = useRef<SDKApiClient | null>(null)
|
|
const syncManagerRef = useRef<StateSyncManager | null>(null)
|
|
const stateRef = useRef(state)
|
|
stateRef.current = state
|
|
|
|
// Initialize API client and sync manager
|
|
useEffect(() => {
|
|
const callbacks = buildSyncCallbacks(setSyncState, setIsOnline, dispatch, stateRef)
|
|
initSyncInfra(enableBackendSync, tenantId, projectId, apiClientRef, syncManagerRef, callbacks)
|
|
return () => cleanupSyncInfra(enableBackendSync, syncManagerRef, apiClientRef)
|
|
}, [enableBackendSync, tenantId, projectId])
|
|
|
|
// Sync current step with URL
|
|
useEffect(() => {
|
|
if (pathname) {
|
|
const step = getStepByUrl(pathname)
|
|
if (step && step.id !== state.currentStep) {
|
|
dispatch({ type: 'SET_CURRENT_STEP', payload: step.id })
|
|
}
|
|
}
|
|
}, [pathname, state.currentStep])
|
|
|
|
// Storage key — per tenant+project
|
|
const storageKey = projectId
|
|
? `${SDK_STORAGE_KEY}-${tenantId}-${projectId}`
|
|
: `${SDK_STORAGE_KEY}-${tenantId}`
|
|
|
|
// Load state on mount (localStorage first, then server)
|
|
useEffect(() => {
|
|
loadInitialState({
|
|
storageKey,
|
|
enableBackendSync,
|
|
projectId,
|
|
syncManager: syncManagerRef.current,
|
|
apiClient: apiClientRef.current,
|
|
dispatch,
|
|
})
|
|
.catch(error => console.error('Failed to load SDK state:', error))
|
|
.finally(() => setIsInitialized(true))
|
|
}, [tenantId, projectId, enableBackendSync, storageKey])
|
|
|
|
// Auto-save to localStorage and sync to server
|
|
useEffect(() => {
|
|
if (!isInitialized || !state.preferences.autoSave) return
|
|
|
|
const saveTimeout = setTimeout(() => {
|
|
try {
|
|
// Save to localStorage (per tenant+project)
|
|
localStorage.setItem(storageKey, JSON.stringify(state))
|
|
|
|
// Sync to server if backend sync is enabled
|
|
if (enableBackendSync && syncManagerRef.current) {
|
|
syncManagerRef.current.queueSync(state)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save SDK state:', error)
|
|
}
|
|
}, 1000)
|
|
|
|
return () => clearTimeout(saveTimeout)
|
|
}, [state, tenantId, projectId, isInitialized, enableBackendSync, storageKey])
|
|
|
|
// Keyboard shortcut for Command Bar
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault()
|
|
setCommandBarOpen(prev => !prev)
|
|
}
|
|
if (e.key === 'Escape' && isCommandBarOpen) {
|
|
setCommandBarOpen(false)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [isCommandBarOpen])
|
|
|
|
// Navigation
|
|
const currentStep = useMemo(() => getStepById(state.currentStep), [state.currentStep])
|
|
|
|
const goToStep = useCallback(
|
|
(stepId: string) => {
|
|
const step = getStepById(stepId)
|
|
if (step) {
|
|
dispatch({ type: 'SET_CURRENT_STEP', payload: stepId })
|
|
const url = projectId ? `${step.url}?project=${projectId}` : step.url
|
|
router.push(url)
|
|
}
|
|
},
|
|
[router, projectId]
|
|
)
|
|
|
|
const goToNextStep = useCallback(() => {
|
|
const nextStep = getNextStep(state.currentStep, state)
|
|
if (nextStep) {
|
|
goToStep(nextStep.id)
|
|
}
|
|
}, [state, goToStep])
|
|
|
|
const goToPreviousStep = useCallback(() => {
|
|
const prevStep = getPreviousStep(state.currentStep, state)
|
|
if (prevStep) {
|
|
goToStep(prevStep.id)
|
|
}
|
|
}, [state, goToStep])
|
|
|
|
const canGoNext = useMemo(() => {
|
|
return getNextStep(state.currentStep, state) !== undefined
|
|
}, [state])
|
|
|
|
const canGoPrevious = useMemo(() => {
|
|
return getPreviousStep(state.currentStep, state) !== undefined
|
|
}, [state])
|
|
|
|
// Progress
|
|
const completionPercentage = useMemo(() => getCompletionPercentage(state), [state])
|
|
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
|
|
const phase2Completion = useMemo(() => getPhaseCompletionPercentage(state, 2), [state])
|
|
|
|
// Package Completion
|
|
const packageCompletion = useMemo(() => {
|
|
const completion: Record<SDKPackageId, number> = {
|
|
'vorbereitung': getPackageCompletionPercentage(state, 'vorbereitung'),
|
|
'analyse': getPackageCompletionPercentage(state, 'analyse'),
|
|
'dokumentation': getPackageCompletionPercentage(state, 'dokumentation'),
|
|
'rechtliche-texte': getPackageCompletionPercentage(state, 'rechtliche-texte'),
|
|
'betrieb': getPackageCompletionPercentage(state, 'betrieb'),
|
|
}
|
|
return completion
|
|
}, [state])
|
|
|
|
// Simple dispatch callbacks
|
|
const setCustomerType = useCallback((type: CustomerType) => dispatch({ type: 'SET_CUSTOMER_TYPE', payload: type }), [])
|
|
const setCompanyProfile = useCallback((profile: CompanyProfile) => dispatch({ type: 'SET_COMPANY_PROFILE', payload: profile }), [])
|
|
const updateCompanyProfile = useCallback((updates: Partial<CompanyProfile>) => dispatch({ type: 'UPDATE_COMPANY_PROFILE', payload: updates }), [])
|
|
const setComplianceScope = useCallback((scope: import('./compliance-scope-types').ComplianceScopeState) => dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scope }), [])
|
|
const updateComplianceScope = useCallback((updates: Partial<import('./compliance-scope-types').ComplianceScopeState>) => dispatch({ type: 'UPDATE_COMPLIANCE_SCOPE', payload: updates }), [])
|
|
const addImportedDocument = useCallback((doc: ImportedDocument) => dispatch({ type: 'ADD_IMPORTED_DOCUMENT', payload: doc }), [])
|
|
const setGapAnalysis = useCallback((analysis: GapAnalysis) => dispatch({ type: 'SET_GAP_ANALYSIS', payload: analysis }), [])
|
|
|
|
// Checkpoints
|
|
const validateCheckpoint = useCallback(
|
|
async (checkpointId: string): Promise<CheckpointStatus> => {
|
|
// Try backend validation if available
|
|
if (enableBackendSync && apiClientRef.current) {
|
|
try {
|
|
const result = await apiClientRef.current.validateCheckpoint(checkpointId, state)
|
|
const status: CheckpointStatus = {
|
|
checkpointId: result.checkpointId,
|
|
passed: result.passed,
|
|
validatedAt: new Date(result.validatedAt),
|
|
validatedBy: result.validatedBy,
|
|
errors: result.errors,
|
|
warnings: result.warnings,
|
|
}
|
|
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
|
|
return status
|
|
} catch {
|
|
// Fall back to local validation
|
|
}
|
|
}
|
|
|
|
// Local validation
|
|
const status = validateCheckpointLocally(checkpointId, state)
|
|
|
|
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
|
|
return status
|
|
},
|
|
[state, enableBackendSync]
|
|
)
|
|
|
|
const overrideCheckpoint = useCallback(async (checkpointId: string, reason: string): Promise<void> => {
|
|
const existing = state.checkpoints[checkpointId]
|
|
const overridden: CheckpointStatus = {
|
|
...existing, checkpointId, passed: true, overrideReason: reason,
|
|
overriddenBy: state.userId, overriddenAt: new Date(),
|
|
errors: [], warnings: existing?.warnings || [],
|
|
}
|
|
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status: overridden } })
|
|
}, [state.checkpoints, state.userId])
|
|
|
|
const getCheckpointStatus = useCallback(
|
|
(checkpointId: string) => state.checkpoints[checkpointId],
|
|
[state.checkpoints]
|
|
)
|
|
|
|
const updateUseCase = useCallback((id: string, data: Partial<UseCaseAssessment>) => dispatch({ type: 'UPDATE_USE_CASE', payload: { id, data } }), [])
|
|
const addRisk = useCallback((risk: Risk) => dispatch({ type: 'ADD_RISK', payload: risk }), [])
|
|
const updateControl = useCallback((id: string, data: Partial<Control>) => dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } }), [])
|
|
const loadDemoData = useCallback((demoState: Partial<SDKState>) => dispatch({ type: 'LOAD_DEMO_DATA', payload: demoState }), [])
|
|
|
|
// Seed demo data via API (stores like real customer data)
|
|
const seedDemoData = useCallback(async (): Promise<{ success: boolean; message: string }> => {
|
|
try {
|
|
// Generate demo state
|
|
const demoState = generateDemoState(tenantId, userId) as SDKState
|
|
|
|
// Save via API (same path as real customer data)
|
|
if (enableBackendSync && apiClientRef.current) {
|
|
await apiClientRef.current.saveState(demoState)
|
|
}
|
|
|
|
// Also save to localStorage for immediate availability
|
|
localStorage.setItem(storageKey, JSON.stringify(demoState))
|
|
|
|
// Update local state
|
|
dispatch({ type: 'LOAD_DEMO_DATA', payload: demoState })
|
|
|
|
return { success: true, message: `Demo-Daten erfolgreich geladen für Tenant ${tenantId}` }
|
|
} catch (error) {
|
|
console.error('Failed to seed demo data:', error)
|
|
return {
|
|
success: false,
|
|
message: error instanceof Error ? error.message : 'Unbekannter Fehler beim Laden der Demo-Daten',
|
|
}
|
|
}
|
|
}, [tenantId, userId, enableBackendSync, storageKey])
|
|
|
|
// Clear demo data
|
|
const clearDemoData = useCallback(async (): Promise<boolean> => {
|
|
try {
|
|
// Delete from API
|
|
if (enableBackendSync && apiClientRef.current) {
|
|
await apiClientRef.current.deleteState()
|
|
}
|
|
|
|
// Clear localStorage
|
|
localStorage.removeItem(storageKey)
|
|
|
|
// Reset local state
|
|
dispatch({ type: 'RESET_STATE' })
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error('Failed to clear demo data:', error)
|
|
return false
|
|
}
|
|
}, [storageKey, enableBackendSync])
|
|
|
|
// Check if demo data is loaded (has use cases with demo- prefix)
|
|
const isDemoDataLoaded = useMemo(() => {
|
|
return state.useCases.length > 0 && state.useCases.some(uc => uc.id.startsWith('demo-'))
|
|
}, [state.useCases])
|
|
|
|
// Persistence
|
|
const saveState = useCallback(async (): Promise<void> => {
|
|
try {
|
|
localStorage.setItem(storageKey, JSON.stringify(state))
|
|
|
|
if (enableBackendSync && syncManagerRef.current) {
|
|
await syncManagerRef.current.forcSync(state)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save SDK state:', error)
|
|
throw error
|
|
}
|
|
}, [state, storageKey, enableBackendSync])
|
|
|
|
const loadState = useCallback(async (): Promise<void> => {
|
|
try {
|
|
if (enableBackendSync && syncManagerRef.current) {
|
|
const serverState = await syncManagerRef.current.loadFromServer()
|
|
if (serverState) {
|
|
dispatch({ type: 'SET_STATE', payload: serverState })
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fall back to localStorage
|
|
const stored = localStorage.getItem(storageKey)
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored)
|
|
dispatch({ type: 'SET_STATE', payload: parsed })
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load SDK state:', error)
|
|
throw error
|
|
}
|
|
}, [storageKey, enableBackendSync])
|
|
|
|
// Force sync to server
|
|
const forceSyncToServer = useCallback(async (): Promise<void> => {
|
|
if (enableBackendSync && syncManagerRef.current) {
|
|
await syncManagerRef.current.forcSync(state)
|
|
}
|
|
}, [state, enableBackendSync])
|
|
|
|
// Project Management
|
|
const createProject = useCallback(
|
|
async (name: string, customerType: CustomerType, copyFromProjectId?: string): Promise<ProjectInfo> => {
|
|
const client = ensureApiClient(apiClientRef, enableBackendSync, tenantId, projectId)
|
|
return createProjectApi(client, name, customerType, copyFromProjectId)
|
|
},
|
|
[enableBackendSync, tenantId, projectId]
|
|
)
|
|
|
|
const listProjectsFn = useCallback(async (): Promise<ProjectInfo[]> => {
|
|
if (!apiClientRef.current && enableBackendSync) {
|
|
apiClientRef.current = getSDKApiClient(tenantId, projectId)
|
|
}
|
|
if (!apiClientRef.current) {
|
|
return []
|
|
}
|
|
return listProjectsApi(apiClientRef.current)
|
|
}, [enableBackendSync, tenantId, projectId])
|
|
|
|
const switchProject = useCallback(
|
|
(newProjectId: string) => {
|
|
const params = new URLSearchParams(window.location.search)
|
|
params.set('project', newProjectId)
|
|
router.push(`/sdk?${params.toString()}`)
|
|
},
|
|
[router]
|
|
)
|
|
|
|
const archiveProjectFn = useCallback(
|
|
async (archiveId: string): Promise<void> => {
|
|
const client = ensureApiClient(apiClientRef, enableBackendSync, tenantId, projectId)
|
|
await archiveProjectApi(client, archiveId)
|
|
},
|
|
[enableBackendSync, tenantId, projectId]
|
|
)
|
|
|
|
const restoreProjectFn = useCallback(
|
|
async (restoreId: string): Promise<ProjectInfo> => {
|
|
const client = ensureApiClient(apiClientRef, enableBackendSync, tenantId, projectId)
|
|
return restoreProjectApi(client, restoreId)
|
|
},
|
|
[enableBackendSync, tenantId, projectId]
|
|
)
|
|
|
|
const permanentlyDeleteProjectFn = useCallback(
|
|
async (deleteId: string): Promise<void> => {
|
|
const client = ensureApiClient(apiClientRef, enableBackendSync, tenantId, projectId)
|
|
await permanentlyDeleteProjectApi(client, deleteId)
|
|
},
|
|
[enableBackendSync, tenantId, projectId]
|
|
)
|
|
|
|
// Export
|
|
const exportState = useCallback(
|
|
async (format: 'json' | 'pdf' | 'zip'): Promise<Blob> => {
|
|
switch (format) {
|
|
case 'json':
|
|
return new Blob([JSON.stringify(state, null, 2)], {
|
|
type: 'application/json',
|
|
})
|
|
|
|
case 'pdf':
|
|
return exportToPDF(state)
|
|
|
|
case 'zip':
|
|
return exportToZIP(state)
|
|
|
|
default:
|
|
throw new Error(`Unknown export format: ${format}`)
|
|
}
|
|
},
|
|
[state]
|
|
)
|
|
|
|
const value: SDKContextValue = {
|
|
state,
|
|
dispatch,
|
|
currentStep,
|
|
goToStep,
|
|
goToNextStep,
|
|
goToPreviousStep,
|
|
canGoNext,
|
|
canGoPrevious,
|
|
completionPercentage,
|
|
phase1Completion,
|
|
phase2Completion,
|
|
packageCompletion,
|
|
setCustomerType,
|
|
setCompanyProfile,
|
|
updateCompanyProfile,
|
|
setComplianceScope,
|
|
updateComplianceScope,
|
|
addImportedDocument,
|
|
setGapAnalysis,
|
|
validateCheckpoint,
|
|
overrideCheckpoint,
|
|
getCheckpointStatus,
|
|
updateUseCase,
|
|
addRisk,
|
|
updateControl,
|
|
saveState,
|
|
loadState,
|
|
loadDemoData,
|
|
seedDemoData,
|
|
clearDemoData,
|
|
isDemoDataLoaded,
|
|
syncState,
|
|
forceSyncToServer,
|
|
isOnline,
|
|
exportState,
|
|
isCommandBarOpen,
|
|
setCommandBarOpen,
|
|
projectId,
|
|
createProject,
|
|
listProjects: listProjectsFn,
|
|
switchProject,
|
|
archiveProject: archiveProjectFn,
|
|
restoreProject: restoreProjectFn,
|
|
permanentlyDeleteProject: permanentlyDeleteProjectFn,
|
|
}
|
|
|
|
return <SDKContext.Provider value={value}>{children}</SDKContext.Provider>
|
|
}
|