# Conflicts: # admin-compliance/lib/sdk/types.ts # admin-compliance/lib/sdk/vendor-compliance/types.ts
510 lines
13 KiB
TypeScript
510 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import React, {
|
|
useReducer,
|
|
useMemo,
|
|
useEffect,
|
|
useState,
|
|
} from 'react'
|
|
|
|
import {
|
|
VendorComplianceContextValue,
|
|
ProcessingActivity,
|
|
VendorStatistics,
|
|
ComplianceStatistics,
|
|
RiskOverview,
|
|
VendorStatus,
|
|
VendorRole,
|
|
RiskLevel,
|
|
FindingType,
|
|
FindingSeverity,
|
|
getRiskLevelFromScore,
|
|
} from './types'
|
|
|
|
import { initialState, vendorComplianceReducer } from './reducer'
|
|
import { VendorComplianceContext } from './hooks'
|
|
import { useVendorComplianceActions } from './use-actions'
|
|
|
|
// Re-export hooks and selectors for barrel
|
|
export {
|
|
useVendorCompliance,
|
|
useVendor,
|
|
useProcessingActivity,
|
|
useVendorContracts,
|
|
useVendorFindings,
|
|
useContractFindings,
|
|
useControlInstancesForEntity,
|
|
} from './hooks'
|
|
|
|
// ==========================================
|
|
// PROVIDER
|
|
// ==========================================
|
|
|
|
interface VendorComplianceProviderProps {
|
|
children: React.ReactNode
|
|
tenantId?: string
|
|
}
|
|
|
|
export function VendorComplianceProvider({
|
|
children,
|
|
tenantId,
|
|
}: VendorComplianceProviderProps) {
|
|
const [state, dispatch] = useReducer(vendorComplianceReducer, initialState)
|
|
const [isInitialized, setIsInitialized] = useState(false)
|
|
|
|
const actions = useVendorComplianceActions(state, dispatch)
|
|
|
|
// ==========================================
|
|
// COMPUTED VALUES
|
|
// ==========================================
|
|
|
|
const vendorStats = useMemo<VendorStatistics>(() => {
|
|
const vendors = state.vendors
|
|
|
|
const byStatus = vendors.reduce(
|
|
(acc, v) => {
|
|
acc[v.status] = (acc[v.status] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<VendorStatus, number>
|
|
)
|
|
|
|
const byRole = vendors.reduce(
|
|
(acc, v) => {
|
|
acc[v.role] = (acc[v.role] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<VendorRole, number>
|
|
)
|
|
|
|
const byRiskLevel = vendors.reduce(
|
|
(acc, v) => {
|
|
const level = getRiskLevelFromScore(v.residualRiskScore / 4)
|
|
acc[level] = (acc[level] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<RiskLevel, number>
|
|
)
|
|
|
|
const now = new Date()
|
|
const pendingReviews = vendors.filter(
|
|
(v) => v.nextReviewDate && new Date(v.nextReviewDate) <= now
|
|
).length
|
|
|
|
const withExpiredContracts = vendors.filter((v) =>
|
|
state.contracts.some(
|
|
(c) =>
|
|
c.vendorId === v.id &&
|
|
c.expirationDate &&
|
|
new Date(c.expirationDate) <= now &&
|
|
c.status === 'ACTIVE'
|
|
)
|
|
).length
|
|
|
|
return {
|
|
total: vendors.length,
|
|
byStatus,
|
|
byRole,
|
|
byRiskLevel,
|
|
pendingReviews,
|
|
withExpiredContracts,
|
|
}
|
|
}, [state.vendors, state.contracts])
|
|
|
|
const complianceStats = useMemo<ComplianceStatistics>(() => {
|
|
const findings = state.findings
|
|
const contracts = state.contracts
|
|
const controlInstances = state.controlInstances
|
|
|
|
const averageComplianceScore =
|
|
contracts.length > 0
|
|
? contracts.reduce((sum, c) => sum + (c.complianceScore || 0), 0) /
|
|
contracts.filter((c) => c.complianceScore !== undefined).length || 0
|
|
: 0
|
|
|
|
const findingsByType = findings.reduce(
|
|
(acc, f) => {
|
|
acc[f.type] = (acc[f.type] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<FindingType, number>
|
|
)
|
|
|
|
const findingsBySeverity = findings.reduce(
|
|
(acc, f) => {
|
|
acc[f.severity] = (acc[f.severity] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<FindingSeverity, number>
|
|
)
|
|
|
|
const openFindings = findings.filter(
|
|
(f) => f.status === 'OPEN' || f.status === 'IN_PROGRESS'
|
|
).length
|
|
|
|
const resolvedFindings = findings.filter(
|
|
(f) => f.status === 'RESOLVED' || f.status === 'FALSE_POSITIVE'
|
|
).length
|
|
|
|
const passedControls = controlInstances.filter(
|
|
(ci) => ci.status === 'PASS'
|
|
).length
|
|
const applicableControls = controlInstances.filter(
|
|
(ci) => ci.status !== 'NOT_APPLICABLE'
|
|
).length
|
|
const controlPassRate =
|
|
applicableControls > 0 ? (passedControls / applicableControls) * 100 : 0
|
|
|
|
return {
|
|
averageComplianceScore,
|
|
findingsByType,
|
|
findingsBySeverity,
|
|
openFindings,
|
|
resolvedFindings,
|
|
controlPassRate,
|
|
}
|
|
}, [state.findings, state.contracts, state.controlInstances])
|
|
|
|
const riskOverview = useMemo<RiskOverview>(() => {
|
|
const vendors = state.vendors
|
|
const findings = state.findings
|
|
|
|
const averageInherentRisk =
|
|
vendors.length > 0
|
|
? vendors.reduce((sum, v) => sum + v.inherentRiskScore, 0) / vendors.length
|
|
: 0
|
|
|
|
const averageResidualRisk =
|
|
vendors.length > 0
|
|
? vendors.reduce((sum, v) => sum + v.residualRiskScore, 0) / vendors.length
|
|
: 0
|
|
|
|
const highRiskVendors = vendors.filter(
|
|
(v) => v.residualRiskScore >= 60
|
|
).length
|
|
|
|
const criticalFindings = findings.filter(
|
|
(f) => f.severity === 'CRITICAL' && f.status === 'OPEN'
|
|
).length
|
|
|
|
const transfersToThirdCountries = vendors.filter((v) =>
|
|
v.processingLocations.some((pl) => !pl.isEU && !pl.isAdequate)
|
|
).length
|
|
|
|
return {
|
|
averageInherentRisk,
|
|
averageResidualRisk,
|
|
highRiskVendors,
|
|
criticalFindings,
|
|
transfersToThirdCountries,
|
|
}
|
|
}, [state.vendors, state.findings])
|
|
|
|
// ==========================================
|
|
// API CALLS
|
|
// ==========================================
|
|
|
|
const apiBase = '/api/sdk/v1/vendor-compliance'
|
|
|
|
const loadData = useCallback(async () => {
|
|
dispatch({ type: 'SET_LOADING', payload: true })
|
|
dispatch({ type: 'SET_ERROR', payload: null })
|
|
|
|
try {
|
|
const [
|
|
activitiesRes,
|
|
vendorsRes,
|
|
contractsRes,
|
|
findingsRes,
|
|
controlsRes,
|
|
controlInstancesRes,
|
|
] = await Promise.all([
|
|
fetch(`${apiBase}/processing-activities`),
|
|
fetch(`${apiBase}/vendors`),
|
|
fetch(`${apiBase}/contracts`),
|
|
fetch(`${apiBase}/findings`),
|
|
fetch(`${apiBase}/controls`),
|
|
fetch(`${apiBase}/control-instances`),
|
|
])
|
|
|
|
if (activitiesRes.ok) {
|
|
const data = await activitiesRes.json()
|
|
dispatch({ type: 'SET_PROCESSING_ACTIVITIES', payload: data.data || [] })
|
|
}
|
|
|
|
if (vendorsRes.ok) {
|
|
const data = await vendorsRes.json()
|
|
dispatch({ type: 'SET_VENDORS', payload: data.data || [] })
|
|
}
|
|
|
|
if (contractsRes.ok) {
|
|
const data = await contractsRes.json()
|
|
dispatch({ type: 'SET_CONTRACTS', payload: data.data || [] })
|
|
}
|
|
|
|
if (findingsRes.ok) {
|
|
const data = await findingsRes.json()
|
|
dispatch({ type: 'SET_FINDINGS', payload: data.data || [] })
|
|
}
|
|
|
|
if (controlsRes.ok) {
|
|
const data = await controlsRes.json()
|
|
dispatch({ type: 'SET_CONTROLS', payload: data.data || [] })
|
|
}
|
|
|
|
if (controlInstancesRes.ok) {
|
|
const data = await controlInstancesRes.json()
|
|
dispatch({ type: 'SET_CONTROL_INSTANCES', payload: data.data || [] })
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load vendor compliance data:', error)
|
|
dispatch({
|
|
type: 'SET_ERROR',
|
|
payload: 'Fehler beim Laden der Daten',
|
|
})
|
|
} finally {
|
|
dispatch({ type: 'SET_LOADING', payload: false })
|
|
}
|
|
}, [apiBase])
|
|
|
|
const refresh = useCallback(async () => {
|
|
await loadData()
|
|
}, [loadData])
|
|
|
|
// ==========================================
|
|
// PROCESSING ACTIVITIES ACTIONS
|
|
// ==========================================
|
|
|
|
const createProcessingActivity = useCallback(
|
|
async (
|
|
data: Omit<ProcessingActivity, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>
|
|
): Promise<ProcessingActivity> => {
|
|
const response = await fetch(`${apiBase}/processing-activities`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Fehler beim Erstellen der Verarbeitungstätigkeit')
|
|
}
|
|
|
|
const result = await response.json()
|
|
const activity = result.data
|
|
|
|
dispatch({ type: 'ADD_PROCESSING_ACTIVITY', payload: activity })
|
|
|
|
return activity
|
|
},
|
|
[apiBase]
|
|
)
|
|
|
|
const deleteProcessingActivity = useCallback(
|
|
async (id: string): Promise<void> => {
|
|
const response = await fetch(`${apiBase}/processing-activities/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Fehler beim Löschen der Verarbeitungstätigkeit')
|
|
}
|
|
|
|
dispatch({ type: 'DELETE_PROCESSING_ACTIVITY', payload: id })
|
|
},
|
|
[apiBase]
|
|
)
|
|
|
|
const duplicateProcessingActivity = useCallback(
|
|
async (id: string): Promise<ProcessingActivity> => {
|
|
const original = state.processingActivities.find((a) => a.id === id)
|
|
if (!original) {
|
|
throw new Error('Verarbeitungstätigkeit nicht gefunden')
|
|
}
|
|
|
|
const { id: _id, vvtId: _vvtId, createdAt: _createdAt, updatedAt: _updatedAt, tenantId: _tenantId, ...rest } = original
|
|
|
|
const newActivity = await createProcessingActivity({
|
|
...rest,
|
|
vvtId: '', // Will be generated by backend
|
|
name: {
|
|
de: `${original.name.de} (Kopie)`,
|
|
en: `${original.name.en} (Copy)`,
|
|
},
|
|
status: 'DRAFT',
|
|
})
|
|
|
|
return newActivity
|
|
},
|
|
[state.processingActivities, createProcessingActivity]
|
|
)
|
|
|
|
// ==========================================
|
|
// VENDOR ACTIONS
|
|
// ==========================================
|
|
|
|
const deleteVendor = useCallback(
|
|
async (id: string): Promise<void> => {
|
|
const response = await fetch(`${apiBase}/vendors/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Fehler beim Löschen des Vendors')
|
|
}
|
|
|
|
dispatch({ type: 'DELETE_VENDOR', payload: id })
|
|
},
|
|
[apiBase]
|
|
)
|
|
|
|
// ==========================================
|
|
// CONTRACT ACTIONS
|
|
// ==========================================
|
|
|
|
const deleteContract = useCallback(
|
|
async (id: string): Promise<void> => {
|
|
const contract = state.contracts.find((c) => c.id === id)
|
|
|
|
const response = await fetch(`${apiBase}/contracts/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Fehler beim Löschen des Vertrags')
|
|
}
|
|
|
|
dispatch({ type: 'DELETE_CONTRACT', payload: id })
|
|
|
|
// Update vendor's contracts list
|
|
if (contract) {
|
|
const vendor = state.vendors.find((v) => v.id === contract.vendorId)
|
|
if (vendor) {
|
|
dispatch({
|
|
type: 'UPDATE_VENDOR',
|
|
payload: {
|
|
id: vendor.id,
|
|
data: { contracts: vendor.contracts.filter((cId) => cId !== id) },
|
|
},
|
|
})
|
|
}
|
|
}
|
|
},
|
|
[apiBase, state.contracts, state.vendors]
|
|
)
|
|
|
|
const startContractReview = useCallback(
|
|
async (contractId: string): Promise<void> => {
|
|
dispatch({
|
|
type: 'UPDATE_CONTRACT',
|
|
payload: { id: contractId, data: { reviewStatus: 'IN_PROGRESS' } },
|
|
})
|
|
|
|
const response = await fetch(`${apiBase}/contracts/${contractId}/review`, {
|
|
method: 'POST',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
dispatch({
|
|
type: 'UPDATE_CONTRACT',
|
|
payload: { id: contractId, data: { reviewStatus: 'FAILED' } },
|
|
})
|
|
const error = await response.json()
|
|
throw new Error(error.error || 'Fehler beim Starten der Vertragsprüfung')
|
|
}
|
|
|
|
const result = await response.json()
|
|
|
|
// Update contract with review results
|
|
dispatch({
|
|
type: 'UPDATE_CONTRACT',
|
|
payload: {
|
|
id: contractId,
|
|
data: {
|
|
reviewStatus: 'COMPLETED',
|
|
reviewCompletedAt: new Date(),
|
|
complianceScore: result.data.complianceScore,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Add findings
|
|
if (result.data.findings && result.data.findings.length > 0) {
|
|
dispatch({ type: 'ADD_FINDINGS', payload: result.data.findings })
|
|
}
|
|
},
|
|
[apiBase]
|
|
)
|
|
|
|
// ==========================================
|
|
// INITIALIZATION
|
|
// ==========================================
|
|
|
|
useEffect(() => {
|
|
if (!isInitialized) {
|
|
actions.loadData()
|
|
setIsInitialized(true)
|
|
}
|
|
}, [isInitialized, actions])
|
|
|
|
// ==========================================
|
|
// CONTEXT VALUE
|
|
// ==========================================
|
|
|
|
const contextValue = useMemo<VendorComplianceContextValue>(
|
|
() => ({
|
|
...state,
|
|
dispatch,
|
|
vendorStats,
|
|
complianceStats,
|
|
riskOverview,
|
|
deleteProcessingActivity,
|
|
duplicateProcessingActivity,
|
|
deleteVendor,
|
|
deleteContract,
|
|
startContractReview,
|
|
loadData,
|
|
refresh,
|
|
}),
|
|
[
|
|
state,
|
|
vendorStats,
|
|
complianceStats,
|
|
riskOverview,
|
|
deleteProcessingActivity,
|
|
duplicateProcessingActivity,
|
|
deleteVendor,
|
|
deleteContract,
|
|
startContractReview,
|
|
loadData,
|
|
refresh,
|
|
]
|
|
)
|
|
|
|
return (
|
|
<VendorComplianceContext.Provider value={contextValue}>
|
|
{children}
|
|
</VendorComplianceContext.Provider>
|
|
)
|
|
}
|
|
|
|
// ==========================================
|
|
// HOOK
|
|
// ==========================================
|
|
|
|
export function useVendorCompliance(): VendorComplianceContextValue {
|
|
const context = useContext(VendorComplianceContext)
|
|
|
|
if (!context) {
|
|
throw new Error(
|
|
'useVendorCompliance must be used within a VendorComplianceProvider'
|
|
)
|
|
}
|
|
|
|
return context
|
|
}
|
|
|