refactor(admin): split lib/sdk/context.tsx (1280 LOC) into focused modules
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>
This commit is contained in:
495
admin-compliance/lib/sdk/context-provider.tsx
Normal file
495
admin-compliance/lib/sdk/context-provider.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
'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>
|
||||
}
|
||||
Reference in New Issue
Block a user