refactor(admin): split SDKSidebar and ScopeWizardTab components
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 49s
CI/CD / test-python-backend-compliance (push) Successful in 46s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 31s
CI/CD / validate-canonical-controls (push) Successful in 23s
CI/CD / Deploy (push) Failing after 7s
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 49s
CI/CD / test-python-backend-compliance (push) Successful in 46s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 31s
CI/CD / validate-canonical-controls (push) Successful in 23s
CI/CD / Deploy (push) Failing after 7s
- SDKSidebar (918→236 LOC): extracted icons to SidebarIcons, sub-components (ProgressBar, PackageIndicator, StepItem, CorpusStalenessInfo, AdditionalModuleItem) to SidebarSubComponents, and the full module nav list to SidebarModuleNav - ScopeWizardTab (794→339 LOC): extracted DatenkategorienBlock9 and its dept mapping constants to DatenkategorienBlock, and question rendering (all switch-case types + help text) to ScopeQuestionRenderer - All files now under 500 LOC hard cap; zero behavior changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
|
||||
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
||||
import { DEPARTMENT_DATA_CATEGORIES } from '@/lib/sdk/vvt-profiling'
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
|
||||
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
||||
import type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { DatenkategorienBlock9 } from './DatenkategorienBlock'
|
||||
import { ScopeQuestionRenderer } from './ScopeQuestionRenderer'
|
||||
|
||||
interface ScopeWizardTabProps {
|
||||
answers: ScopeProfilingAnswer[]
|
||||
@@ -28,18 +29,15 @@ export function ScopeWizardTab({
|
||||
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
|
||||
const totalProgress = getTotalProgress(answers)
|
||||
|
||||
// Load companyProfile from SDK context
|
||||
const { state: sdkState } = useSDK()
|
||||
const companyProfile = sdkState.companyProfile
|
||||
|
||||
// Track which question IDs were prefilled from profile
|
||||
const [prefilledIds, setPrefilledIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Auto-prefill from company profile on mount if answers are empty
|
||||
useEffect(() => {
|
||||
if (companyProfile && answers.length === 0) {
|
||||
const prefilled = prefillFromCompanyProfile(companyProfile)
|
||||
// Also inject auto-filled scoring answers for questions removed from UI
|
||||
const autoFilled = getAutoFilledScoringAnswers(companyProfile)
|
||||
const allPrefilled = [...prefilled, ...autoFilled]
|
||||
if (allPrefilled.length > 0) {
|
||||
@@ -47,7 +45,6 @@ export function ScopeWizardTab({
|
||||
setPrefilledIds(new Set(allPrefilled.map(a => a.questionId)))
|
||||
}
|
||||
}
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
@@ -61,7 +58,6 @@ export function ScopeWizardTab({
|
||||
} else {
|
||||
onAnswersChange([...answers, { questionId, value }])
|
||||
}
|
||||
// Remove from prefilled set when user manually changes
|
||||
if (prefilledIds.has(questionId)) {
|
||||
setPrefilledIds(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -78,7 +74,6 @@ export function ScopeWizardTab({
|
||||
const prefilled = prefillFromCompanyProfile(companyProfile)
|
||||
const autoFilled = getAutoFilledScoringAnswers(companyProfile)
|
||||
const allPrefilled = [...prefilled, ...autoFilled]
|
||||
// Merge with existing answers: prefilled values for questions not yet answered
|
||||
const existingIds = new Set(answers.map(a => a.questionId))
|
||||
const newAnswers = [...answers]
|
||||
const newPrefilledIds = new Set(prefilledIds)
|
||||
@@ -101,242 +96,18 @@ export function ScopeWizardTab({
|
||||
}, [currentBlockIndex, canEvaluate, onEvaluate])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentBlockIndex > 0) {
|
||||
setCurrentBlockIndex(currentBlockIndex - 1)
|
||||
}
|
||||
if (currentBlockIndex > 0) setCurrentBlockIndex(currentBlockIndex - 1)
|
||||
}, [currentBlockIndex])
|
||||
|
||||
const toggleHelp = useCallback((questionId: string) => {
|
||||
setExpandedHelp(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(questionId)) {
|
||||
next.delete(questionId)
|
||||
} else {
|
||||
next.add(questionId)
|
||||
}
|
||||
if (next.has(questionId)) next.delete(questionId)
|
||||
else next.add(questionId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Check if a question was prefilled from company profile
|
||||
const isPrefilledFromProfile = useCallback((questionId: string) => {
|
||||
return prefilledIds.has(questionId)
|
||||
}, [prefilledIds])
|
||||
|
||||
const renderHelpText = (question: ScopeProfilingQuestion) => {
|
||||
if (!question.helpText) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-blue-400 hover:text-blue-600 inline-flex items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
toggleHelp(question.id)
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedHelp.has(question.id) && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2.5 bg-blue-50 rounded-lg text-xs text-blue-700 leading-relaxed">
|
||||
<svg className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{question.helpText}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPrefilledBadge = (questionId: string) => {
|
||||
if (!isPrefilledFromProfile(questionId)) return null
|
||||
return (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||
Aus Profil
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderQuestion = (question: ScopeProfilingQuestion) => {
|
||||
const currentValue = getAnswerValue(answers, question.id)
|
||||
|
||||
switch (question.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
</span>
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{renderPrefilledBadge(question.id)}
|
||||
{renderHelpText(question)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAnswerChange(question.id, true)}
|
||||
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
|
||||
currentValue === true
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAnswerChange(question.id, false)}
|
||||
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
|
||||
currentValue === false
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'single':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
</span>
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{renderPrefilledBadge(question.id)}
|
||||
{renderHelpText(question)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{question.options?.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleAnswerChange(question.id, option.value)}
|
||||
className={`w-full text-left py-3 px-4 rounded-lg border-2 transition-all ${
|
||||
currentValue === option.value
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'multi':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
</span>
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{renderPrefilledBadge(question.id)}
|
||||
{renderHelpText(question)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{question.options?.map((option) => {
|
||||
const selectedValues = Array.isArray(currentValue) ? currentValue as string[] : []
|
||||
const isChecked = selectedValues.includes(option.value)
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-center gap-3 py-3 px-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
isChecked
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-300 bg-white hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const newValues = e.target.checked
|
||||
? [...selectedValues, option.value]
|
||||
: selectedValues.filter((v) => v !== option.value)
|
||||
handleAnswerChange(question.id, newValues)
|
||||
}}
|
||||
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span className={isChecked ? 'text-purple-700 font-medium' : 'text-gray-700'}>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
</span>
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{renderPrefilledBadge(question.id)}
|
||||
{renderHelpText(question)}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={currentValue != null ? String(currentValue) : ''}
|
||||
onChange={(e) => handleAnswerChange(question.id, parseInt(e.target.value, 10))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Zahl eingeben"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{question.question}
|
||||
</span>
|
||||
{question.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{renderPrefilledBadge(question.id)}
|
||||
{renderHelpText(question)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue != null ? String(currentValue) : ''}
|
||||
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Text eingeben"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-full">
|
||||
{/* Left Sidebar - Block Navigation */}
|
||||
@@ -350,7 +121,6 @@ export function ScopeWizardTab({
|
||||
const unanswered = getUnansweredRequiredQuestions(answers, block.id)
|
||||
const hasRequired = block.questions.some(q => q.required)
|
||||
const allRequiredDone = hasRequired && unanswered.length === 0
|
||||
// For optional-only blocks: check if any questions were answered
|
||||
const answeredIds = new Set(answers.map(a => a.questionId))
|
||||
const hasAnyAnswer = block.questions.some(q => answeredIds.has(q.id))
|
||||
const optionalDone = !hasRequired && hasAnyAnswer
|
||||
@@ -380,19 +150,13 @@ export function ScopeWizardTab({
|
||||
) : !hasRequired ? (
|
||||
<span className="text-xs text-gray-400">(nur optional)</span>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-orange-600">
|
||||
{unanswered.length} offen
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-orange-600">{unanswered.length} offen</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
allRequiredDone || optionalDone
|
||||
? 'bg-green-500'
|
||||
: !hasRequired
|
||||
? 'bg-gray-300'
|
||||
: 'bg-orange-400'
|
||||
allRequiredDone || optionalDone ? 'bg-green-500' : !hasRequired ? 'bg-gray-300' : 'bg-orange-400'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
@@ -428,8 +192,6 @@ export function ScopeWizardTab({
|
||||
{(() => {
|
||||
const allUnanswered = getUnansweredRequiredQuestions(answers)
|
||||
if (allUnanswered.length === 0) return null
|
||||
|
||||
// Group by block
|
||||
const byBlock = new Map<string, { blockTitle: string; blockIndex: number; count: number }>()
|
||||
for (const item of allUnanswered) {
|
||||
if (!byBlock.has(item.blockId)) {
|
||||
@@ -438,7 +200,6 @@ export function ScopeWizardTab({
|
||||
}
|
||||
byBlock.get(item.blockId)!.count++
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-orange-600 font-medium">⚠ Offene Pflichtfragen:</span>
|
||||
@@ -477,7 +238,7 @@ export function ScopeWizardTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* "Aus Profil" Info Box — shown for blocks that have auto-filled data */}
|
||||
{/* "Aus Profil" Info Box */}
|
||||
{companyProfile && (() => {
|
||||
const profileItems = getProfileInfoForBlock(companyProfile, currentBlock.id as ScopeQuestionBlockId)
|
||||
if (profileItems.length === 0) return null
|
||||
@@ -516,21 +277,23 @@ export function ScopeWizardTab({
|
||||
{/* Questions */}
|
||||
<div className="space-y-6">
|
||||
{currentBlock.id === 'datenkategorien_detail' ? (
|
||||
<DatenkategorienBlock9
|
||||
answers={answers}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
/>
|
||||
<DatenkategorienBlock9 answers={answers} onAnswerChange={handleAnswerChange} />
|
||||
) : (
|
||||
currentBlock.questions.map((question) => {
|
||||
const isAnswered = answers.some(a => a.questionId === question.id)
|
||||
const borderClass = question.required
|
||||
? isAnswered
|
||||
? 'border-l-4 border-l-green-400 pl-4'
|
||||
: 'border-l-4 border-l-orange-400 pl-4'
|
||||
? isAnswered ? 'border-l-4 border-l-green-400 pl-4' : 'border-l-4 border-l-orange-400 pl-4'
|
||||
: ''
|
||||
return (
|
||||
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
|
||||
{renderQuestion(question)}
|
||||
<ScopeQuestionRenderer
|
||||
question={question}
|
||||
answers={answers}
|
||||
prefilledIds={prefilledIds}
|
||||
expandedHelp={expandedHelp}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
onToggleHelp={toggleHelp}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -574,221 +337,3 @@ export function ScopeWizardTab({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BLOCK 9: Datenkategorien pro Abteilung (aufklappbare Kacheln)
|
||||
// =============================================================================
|
||||
|
||||
/** Mapping Block 8 vvt_departments values → DEPARTMENT_DATA_CATEGORIES keys */
|
||||
const DEPT_VALUE_TO_KEY: Record<string, string[]> = {
|
||||
personal: ['dept_hr', 'dept_recruiting'],
|
||||
finanzen: ['dept_finance'],
|
||||
vertrieb: ['dept_sales'],
|
||||
marketing: ['dept_marketing'],
|
||||
it: ['dept_it'],
|
||||
recht: ['dept_recht'],
|
||||
kundenservice: ['dept_support'],
|
||||
produktion: ['dept_produktion'],
|
||||
logistik: ['dept_logistik'],
|
||||
einkauf: ['dept_einkauf'],
|
||||
facility: ['dept_facility'],
|
||||
}
|
||||
|
||||
/** Mapping department key → scope question ID for Block 9 */
|
||||
const DEPT_KEY_TO_QUESTION: Record<string, string> = {
|
||||
dept_hr: 'dk_dept_hr',
|
||||
dept_recruiting: 'dk_dept_recruiting',
|
||||
dept_finance: 'dk_dept_finance',
|
||||
dept_sales: 'dk_dept_sales',
|
||||
dept_marketing: 'dk_dept_marketing',
|
||||
dept_support: 'dk_dept_support',
|
||||
dept_it: 'dk_dept_it',
|
||||
dept_recht: 'dk_dept_recht',
|
||||
dept_produktion: 'dk_dept_produktion',
|
||||
dept_logistik: 'dk_dept_logistik',
|
||||
dept_einkauf: 'dk_dept_einkauf',
|
||||
dept_facility: 'dk_dept_facility',
|
||||
}
|
||||
|
||||
function DatenkategorienBlock9({
|
||||
answers,
|
||||
onAnswerChange,
|
||||
}: {
|
||||
answers: ScopeProfilingAnswer[]
|
||||
onAnswerChange: (questionId: string, value: string | string[] | boolean | number) => void
|
||||
}) {
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set())
|
||||
const [initializedDepts, setInitializedDepts] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get selected departments from Block 8
|
||||
const deptAnswer = answers.find(a => a.questionId === 'vvt_departments')
|
||||
const selectedDepts = Array.isArray(deptAnswer?.value) ? (deptAnswer.value as string[]) : []
|
||||
|
||||
// Resolve which department keys are active
|
||||
const activeDeptKeys: string[] = []
|
||||
for (const deptValue of selectedDepts) {
|
||||
const keys = DEPT_VALUE_TO_KEY[deptValue]
|
||||
if (keys) {
|
||||
for (const k of keys) {
|
||||
if (!activeDeptKeys.includes(k)) activeDeptKeys.push(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDept = (deptKey: string) => {
|
||||
setExpandedDepts(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(deptKey)) {
|
||||
next.delete(deptKey)
|
||||
} else {
|
||||
next.add(deptKey)
|
||||
// Prefill typical categories on first expand
|
||||
if (!initializedDepts.has(deptKey)) {
|
||||
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
|
||||
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
|
||||
if (config && questionId) {
|
||||
const existing = answers.find(a => a.questionId === questionId)
|
||||
if (!existing) {
|
||||
const typicalIds = config.categories.filter(c => c.isTypical).map(c => c.id)
|
||||
onAnswerChange(questionId, typicalIds)
|
||||
}
|
||||
}
|
||||
setInitializedDepts(p => new Set(p).add(deptKey))
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleCategoryToggle = (deptKey: string, catId: string) => {
|
||||
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
|
||||
if (!questionId) return
|
||||
const existing = answers.find(a => a.questionId === questionId)
|
||||
const current = Array.isArray(existing?.value) ? (existing.value as string[]) : []
|
||||
const updated = current.includes(catId)
|
||||
? current.filter(id => id !== catId)
|
||||
: [...current, catId]
|
||||
onAnswerChange(questionId, updated)
|
||||
}
|
||||
|
||||
if (activeDeptKeys.length === 0) {
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Bitte waehlen Sie zuerst in <strong>Block 8 (Verarbeitungstaetigkeiten)</strong> die
|
||||
Abteilungen aus, in denen personenbezogene Daten verarbeitet werden.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{activeDeptKeys.map(deptKey => {
|
||||
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
|
||||
if (!config) return null
|
||||
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
|
||||
const isExpanded = expandedDepts.has(deptKey)
|
||||
const existing = answers.find(a => a.questionId === questionId)
|
||||
const selectedCategories = Array.isArray(existing?.value) ? (existing.value as string[]) : []
|
||||
const hasArt9Selected = config.categories
|
||||
.filter(c => c.isArt9)
|
||||
.some(c => selectedCategories.includes(c.id))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deptKey}
|
||||
className={`border rounded-xl overflow-hidden transition-all ${
|
||||
isExpanded ? 'border-purple-400 bg-white shadow-sm' : 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDept(deptKey)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{config.icon}</span>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-gray-900">{config.label}</span>
|
||||
{selectedCategories.length > 0 && (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
({selectedCategories.length} Kategorien)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasArt9Selected && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
|
||||
Art. 9
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable categories panel */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 px-4 pt-3 pb-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Datenkategorien
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{config.categories.map(cat => {
|
||||
const isChecked = selectedCategories.includes(cat.id)
|
||||
return (
|
||||
<label
|
||||
key={cat.id}
|
||||
className={`flex items-start gap-3 p-2.5 rounded-lg cursor-pointer transition-colors ${
|
||||
cat.isArt9
|
||||
? isChecked ? 'bg-orange-50 hover:bg-orange-100' : 'bg-gray-50 hover:bg-orange-50'
|
||||
: isChecked ? 'bg-purple-50 hover:bg-purple-100' : 'bg-gray-50 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleCategoryToggle(deptKey, cat.id)}
|
||||
className={`w-4 h-4 mt-0.5 rounded ${cat.isArt9 ? 'text-orange-500' : 'text-purple-600'}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
|
||||
{cat.isArt9 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
|
||||
Art. 9
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{cat.info}</p>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Art. 9 warning */}
|
||||
{hasArt9Selected && (
|
||||
<div className="mt-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<p className="text-xs text-orange-800">
|
||||
<span className="font-semibold">Art. 9 DSGVO:</span> Sie verarbeiten besondere Kategorien
|
||||
personenbezogener Daten. Eine zusaetzliche Rechtsgrundlage nach Art. 9 Abs. 2 DSGVO ist
|
||||
erforderlich (z.B. § 26 Abs. 3 BDSG fuer Beschaeftigtendaten).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user