feat(scope+vvt): Datenkategorien in Scope Block 9 verschieben, VVT Generator-Tab entfernen
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 36s
CI / test-python-backend-compliance (push) Successful in 36s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 20s

- ScopeQuestionBlockId um 'datenkategorien_detail' erweitert
- Block 9 mit 6 Abteilungs-Datenkategorie-Fragen (dk_dept_hr, dk_dept_recruiting, etc.)
- ScopeWizardTab: aufklappbare Kacheln fuer Block 9, gefiltert nach Block 8 Abteilungswahl
- VVT: Generator-Tab komplett entfernt (3 statt 4 Tabs)
- VVT Verzeichnis: "Aus Scope-Analyse generieren" Button mit Preview + Uebernehmen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-10 14:47:20 +01:00
parent e3fb81fc0d
commit a6818b39c5
4 changed files with 431 additions and 383 deletions

View File

@@ -3,11 +3,10 @@
/**
* VVT — Verarbeitungsverzeichnis (Art. 30 DSGVO)
*
* 4 Tabs:
* 1. Verzeichnis (Uebersicht aller Verarbeitungstaetigkeiten)
* 3 Tabs:
* 1. Verzeichnis (Uebersicht + "Aus Scope generieren")
* 2. Verarbeitung bearbeiten (Detail-Editor)
* 3. Generator (Profiling-Fragebogen)
* 4. Export & Compliance
* 3. Export & Compliance
*/
import { useState, useEffect, useCallback } from 'react'
@@ -32,23 +31,15 @@ import {
} from '@/lib/sdk/vvt-types'
import type { VVTActivity, VVTOrganizationHeader, BusinessFunction } from '@/lib/sdk/vvt-types'
import {
PROFILING_STEPS,
PROFILING_QUESTIONS,
DEPARTMENT_DATA_CATEGORIES,
getQuestionsForStep,
getStepProgress,
getTotalProgress,
generateActivities,
prefillFromScopeAnswers,
} from '@/lib/sdk/vvt-profiling'
import type { ProfilingAnswers } from '@/lib/sdk/vvt-profiling'
// =============================================================================
// CONSTANTS
// =============================================================================
type Tab = 'verzeichnis' | 'editor' | 'generator' | 'export'
const PROFILING_STORAGE_KEY = 'bp_vvt_profiling'
type Tab = 'verzeichnis' | 'editor' | 'export'
// =============================================================================
// API CLIENT
@@ -202,24 +193,13 @@ export default function VVTPage() {
const [tab, setTab] = useState<Tab>('verzeichnis')
const [activities, setActivities] = useState<VVTActivity[]>([])
const [orgHeader, setOrgHeader] = useState<VVTOrganizationHeader>(createDefaultOrgHeader())
const [profilingAnswers, setProfilingAnswers] = useState<ProfilingAnswers>({})
const [editingId, setEditingId] = useState<string | null>(null)
const [filter, setFilter] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'date' | 'status'>('name')
const [generatorStep, setGeneratorStep] = useState(1)
const [generatorPreview, setGeneratorPreview] = useState<VVTActivity[] | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [apiError, setApiError] = useState<string | null>(null)
// Load profiling answers from localStorage (UI state only)
useEffect(() => {
try {
const stored = localStorage.getItem(PROFILING_STORAGE_KEY)
if (stored) setProfilingAnswers(JSON.parse(stored))
} catch { /* ignore */ }
}, [])
// Load activities + org header from API
useEffect(() => {
async function loadFromApi() {
@@ -242,13 +222,6 @@ export default function VVTPage() {
loadFromApi()
}, [])
const updateProfilingAnswers = useCallback((prof: ProfilingAnswers) => {
setProfilingAnswers(prof)
try {
localStorage.setItem(PROFILING_STORAGE_KEY, JSON.stringify(prof))
} catch { /* ignore */ }
}, [])
// Computed stats
const activeCount = activities.filter(a => a.status === 'APPROVED').length
const draftCount = activities.filter(a => a.status === 'DRAFT').length
@@ -279,7 +252,6 @@ export default function VVTPage() {
const tabs: { id: Tab; label: string; count?: number }[] = [
{ id: 'verzeichnis', label: 'Verzeichnis', count: activities.length },
{ id: 'editor', label: 'Verarbeitung bearbeiten' },
{ id: 'generator', label: 'Generator' },
{ id: 'export', label: 'Export & Compliance' },
]
@@ -342,6 +314,7 @@ export default function VVTPage() {
setSearchQuery={setSearchQuery}
sortBy={sortBy}
setSortBy={setSortBy}
scopeAnswers={state.complianceScope?.answers}
onEdit={(id) => { setEditingId(id); setTab('editor') }}
onNew={async () => {
const vvtId = generateVVTId(activities.map(a => a.vvtId))
@@ -365,6 +338,18 @@ export default function VVTPage() {
console.error(err)
}
}}
onAdoptGenerated={async (newActivities) => {
const created: VVTActivity[] = []
for (const act of newActivities) {
try {
const saved = await apiCreateActivity(act)
created.push(saved)
} catch (err) {
console.error('Failed to create activity from scope:', err)
}
}
if (created.length > 0) setActivities(prev => [...prev, ...created])
}}
/>
)}
@@ -386,32 +371,6 @@ export default function VVTPage() {
/>
)}
{tab === 'generator' && (
<TabGenerator
step={generatorStep}
setStep={setGeneratorStep}
answers={profilingAnswers}
setAnswers={updateProfilingAnswers}
preview={generatorPreview}
setPreview={setGeneratorPreview}
onAdoptAll={async (newActivities) => {
const created: VVTActivity[] = []
for (const act of newActivities) {
try {
const saved = await apiCreateActivity(act)
created.push(saved)
} catch (err) {
console.error('Failed to create activity from generator:', err)
}
}
if (created.length > 0) setActivities(prev => [...prev, ...created])
setGeneratorPreview(null)
setGeneratorStep(1)
setTab('verzeichnis')
}}
/>
)}
{tab === 'export' && (
<TabExport
activities={activities}
@@ -438,7 +397,7 @@ export default function VVTPage() {
function TabVerzeichnis({
activities, allActivities, activeCount, draftCount, thirdCountryCount, art9Count,
filter, setFilter, searchQuery, setSearchQuery, sortBy, setSortBy,
onEdit, onNew, onDelete,
scopeAnswers, onEdit, onNew, onDelete, onAdoptGenerated,
}: {
activities: VVTActivity[]
allActivities: VVTActivity[]
@@ -452,12 +411,100 @@ function TabVerzeichnis({
setSearchQuery: (q: string) => void
sortBy: string
setSortBy: (s: 'name' | 'date' | 'status') => void
scopeAnswers?: import('@/lib/sdk/compliance-scope-types').ScopeProfilingAnswer[]
onEdit: (id: string) => void
onNew: () => void
onDelete: (id: string) => void
onAdoptGenerated: (activities: VVTActivity[]) => void
}) {
const [scopePreview, setScopePreview] = useState<VVTActivity[] | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerateFromScope = useCallback(() => {
if (!scopeAnswers) return
setIsGenerating(true)
try {
const profilingAnswers = prefillFromScopeAnswers(scopeAnswers)
const result = generateActivities(profilingAnswers)
setScopePreview(result.generatedActivities)
} finally {
setIsGenerating(false)
}
}, [scopeAnswers])
const handleAdoptPreview = useCallback(() => {
if (!scopePreview) return
onAdoptGenerated(scopePreview)
setScopePreview(null)
}, [scopePreview, onAdoptGenerated])
// Preview mode for generated activities
if (scopePreview) {
return (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Generierte Verarbeitungen</h3>
<p className="text-sm text-gray-500 mb-4">
Basierend auf Ihrer Scope-Analyse wurden {scopePreview.length} Verarbeitungstaetigkeiten generiert.
Sie koennen einzelne Eintraege abwaehlen, bevor Sie diese uebernehmen.
</p>
<div className="space-y-2">
{scopePreview.map((a, i) => (
<div key={a.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<input type="checkbox" defaultChecked className="w-4 h-4 text-purple-600 rounded"
onChange={(e) => {
if (!e.target.checked) {
setScopePreview(scopePreview.filter((_, j) => j !== i))
}
}} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">{a.vvtId}</span>
<span className="text-sm font-medium text-gray-900">{a.name}</span>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">{BUSINESS_FUNCTION_LABELS[a.businessFunction]}</span>
</div>
<p className="text-xs text-gray-500 truncate">{a.description}</p>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<button onClick={() => setScopePreview(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
Abbrechen
</button>
<button onClick={handleAdoptPreview} className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Alle {scopePreview.length} uebernehmen
</button>
</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Scope Generate Button */}
{scopeAnswers && scopeAnswers.length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-blue-900">Aus Scope-Analyse generieren</h4>
<p className="text-xs text-blue-700 mt-0.5">
Erstellen Sie automatisch Verarbeitungstaetigkeiten basierend auf Ihren Scope-Profiling-Antworten.
</p>
</div>
<button
onClick={handleGenerateFromScope}
disabled={isGenerating}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm whitespace-nowrap disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{isGenerating ? 'Generiere...' : 'Generieren'}
</button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label="Gesamt" value={allActivities.length} color="gray" />
@@ -537,7 +584,7 @@ function TabVerzeichnis({
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Verarbeitungen gefunden</h3>
<p className="mt-2 text-gray-500 max-w-md mx-auto">
Erstellen Sie eine neue Verarbeitung manuell oder nutzen Sie den Generator, um automatisch Eintraege aus einem Fragebogen zu erzeugen.
Erstellen Sie eine neue Verarbeitung manuell oder generieren Sie Eintraege automatisch aus Ihrer Scope-Analyse.
</p>
</div>
)}
@@ -1004,314 +1051,7 @@ function TabEditor({
}
// =============================================================================
// TAB 3: GENERATOR
// =============================================================================
function TabGenerator({
step, setStep, answers, setAnswers, preview, setPreview, onAdoptAll,
}: {
step: number
setStep: (s: number) => void
answers: ProfilingAnswers
setAnswers: (a: ProfilingAnswers) => void
preview: VVTActivity[] | null
setPreview: (p: VVTActivity[] | null) => void
onAdoptAll: (activities: VVTActivity[]) => void
}) {
const questions = getQuestionsForStep(step)
const totalSteps = PROFILING_STEPS.length
const currentStepInfo = PROFILING_STEPS.find(s => s.step === step)
const totalProgress = getTotalProgress(answers)
const handleGenerate = () => {
const result = generateActivities(answers)
setPreview(result.generatedActivities)
}
// Preview mode
if (preview) {
return (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Generierte Verarbeitungen</h3>
<p className="text-sm text-gray-500 mb-4">
Basierend auf Ihren Antworten wurden {preview.length} Verarbeitungstaetigkeiten generiert.
Sie koennen einzelne Eintraege abwaehlen, bevor Sie diese uebernehmen.
</p>
<div className="space-y-2">
{preview.map((a, i) => (
<div key={a.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<input type="checkbox" defaultChecked className="w-4 h-4 text-purple-600 rounded"
onChange={(e) => {
if (!e.target.checked) {
setPreview(preview.filter((_, j) => j !== i))
}
}} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">{a.vvtId}</span>
<span className="text-sm font-medium text-gray-900">{a.name}</span>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">{BUSINESS_FUNCTION_LABELS[a.businessFunction]}</span>
</div>
<p className="text-xs text-gray-500 truncate">{a.description}</p>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<button onClick={() => setPreview(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
Zurueck zum Fragebogen
</button>
<button onClick={() => onAdoptAll(preview)} className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Alle {preview.length} uebernehmen
</button>
</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Progress Bar */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Fortschritt: {totalProgress}%</span>
<span className="text-sm text-gray-500">Schritt {step} von {totalSteps}</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-purple-600 rounded-full transition-all" style={{ width: `${(step / totalSteps) * 100}%` }} />
</div>
<div className="flex items-center gap-1 mt-3">
{PROFILING_STEPS.map(s => (
<button
key={s.step}
onClick={() => setStep(s.step)}
className={`flex-1 h-1.5 rounded-full transition-colors ${
s.step === step ? 'bg-purple-600' : s.step < step ? 'bg-purple-300' : 'bg-gray-200'
}`}
/>
))}
</div>
</div>
{/* Step Info */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900">{currentStepInfo?.title}</h3>
<p className="text-sm text-gray-500 mt-1">{currentStepInfo?.description}</p>
<div className="mt-6 space-y-6">
{questions.map(q => {
const deptConfig = DEPARTMENT_DATA_CATEGORIES[q.id]
const isDeptQuestion = q.type === 'boolean' && q.step === 2 && deptConfig
const isActive = answers[q.id] === true
const categoriesKey = `${q.id}_categories`
const selectedCategories = (answers[categoriesKey] as string[] | undefined) || []
// Initialize typical categories when dept is activated
const handleDeptToggle = (value: boolean) => {
const updated = { ...answers, [q.id]: value }
if (value && deptConfig && !answers[categoriesKey]) {
// Prefill typical categories
updated[categoriesKey] = deptConfig.categories
.filter(c => c.isTypical)
.map(c => c.id)
}
setAnswers(updated)
}
const handleCategoryToggle = (catId: string) => {
const current = (answers[categoriesKey] as string[] | undefined) || []
const updated = current.includes(catId)
? current.filter(id => id !== catId)
: [...current, catId]
setAnswers({ ...answers, [categoriesKey]: updated })
}
// Expandable department tile (Step 2)
if (isDeptQuestion) {
const hasArt9Selected = deptConfig.categories
.filter(c => c.isArt9)
.some(c => selectedCategories.includes(c.id))
return (
<div key={q.id} className={`border rounded-xl overflow-hidden transition-all ${
isActive ? 'border-purple-400 bg-white shadow-sm' : 'border-gray-200 bg-white'
}`}>
{/* Header row with Ja/Nein */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<span className="text-xl">{deptConfig.icon}</span>
<div>
<span className="text-sm font-medium text-gray-900">{deptConfig.label}</span>
{q.helpText && <p className="text-xs text-gray-400">{q.helpText}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleDeptToggle(true)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
isActive ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Ja
</button>
<button
type="button"
onClick={() => handleDeptToggle(false)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
answers[q.id] === false ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Nein
</button>
</div>
</div>
{/* Expandable categories panel */}
{isActive && (
<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">
Typische Datenkategorien
</p>
<div className="space-y-1.5">
{deptConfig.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(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>
)
}
// Standard rendering for non-department questions
return (
<div key={q.id}>
<label className="block text-sm font-medium text-gray-700 mb-2">{q.question}</label>
{q.helpText && <p className="text-xs text-gray-400 mb-2">{q.helpText}</p>}
{q.type === 'boolean' && (
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name={q.id} checked={answers[q.id] === true}
onChange={() => setAnswers({ ...answers, [q.id]: true })}
className="w-4 h-4 text-purple-600" />
<span className="text-sm">Ja</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name={q.id} checked={answers[q.id] === false}
onChange={() => setAnswers({ ...answers, [q.id]: false })}
className="w-4 h-4 text-purple-600" />
<span className="text-sm">Nein</span>
</label>
</div>
)}
{q.type === 'single_choice' && q.options && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{q.options.map(opt => (
<label
key={opt.value}
className={`flex items-center gap-2 p-3 border rounded-lg cursor-pointer transition-colors ${
answers[q.id] === opt.value ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<input type="radio" name={q.id} value={opt.value}
checked={answers[q.id] === opt.value}
onChange={() => setAnswers({ ...answers, [q.id]: opt.value })}
className="w-4 h-4 text-purple-600" />
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
)}
{q.type === 'number' && (
<input type="number" value={typeof answers[q.id] === 'number' ? answers[q.id] as number : ''}
onChange={(e) => setAnswers({ ...answers, [q.id]: parseInt(e.target.value) || 0 })}
className="w-40 px-3 py-2 border border-gray-300 rounded-lg"
placeholder="Anzahl" />
)}
{q.type === 'text' && (
<input type="text" value={(answers[q.id] as string) || ''}
onChange={(e) => setAnswers({ ...answers, [q.id]: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
)}
</div>
)
})}
</div>
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => setStep(Math.max(1, step - 1))}
disabled={step === 1}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurueck
</button>
<div className="flex items-center gap-3">
{step < totalSteps ? (
<button onClick={() => setStep(step + 1)} className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Weiter
</button>
) : (
<button onClick={handleGenerate} className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
Verarbeitungen generieren
</button>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// TAB 4: EXPORT & COMPLIANCE
// TAB 3: EXPORT & COMPLIANCE
// =============================================================================
function TabExport({

View File

@@ -2,6 +2,7 @@
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 type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
import { useSDK } from '@/lib/sdk'
@@ -509,19 +510,26 @@ export function ScopeWizardTab({
{/* Questions */}
<div className="space-y-6">
{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'
: ''
return (
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
{renderQuestion(question)}
</div>
)
})}
{currentBlock.id === 'datenkategorien_detail' ? (
<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'
: ''
return (
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
{renderQuestion(question)}
</div>
)
})
)}
</div>
</div>
@@ -561,3 +569,209 @@ 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'],
kundenservice: ['dept_support'],
}
/** 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',
}
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>
)
}

View File

@@ -6,6 +6,7 @@ import type {
ComplianceScopeState,
} from './compliance-scope-types'
import type { CompanyProfile } from './types'
import { DEPARTMENT_DATA_CATEGORIES } from './vvt-profiling'
/**
* Block 1: Organisation & Reife
@@ -579,6 +580,97 @@ const BLOCK_8_VVT: ScopeQuestionBlock = {
],
}
/**
* Block 9: Datenkategorien pro Abteilung
* Generiert Fragen dynamisch aus DEPARTMENT_DATA_CATEGORIES
*/
const BLOCK_9_DATENKATEGORIEN: ScopeQuestionBlock = {
id: 'datenkategorien_detail',
title: 'Datenkategorien pro Abteilung',
description: 'Detaillierte Erfassung der Datenkategorien je Abteilung — basierend auf Ihrer Abteilungswahl in Block 8',
order: 9,
questions: [
{
id: 'dk_dept_hr',
type: 'multi',
question: 'Welche Datenkategorien verarbeitet Ihre Personalabteilung?',
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer den HR-Bereich',
required: false,
options: DEPARTMENT_DATA_CATEGORIES.dept_hr.categories.map(c => ({
value: c.id,
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
})),
scoreWeights: { risk: 6, complexity: 4, assurance: 5 },
mapsToVVTQuestion: 'dept_hr_categories',
},
{
id: 'dk_dept_recruiting',
type: 'multi',
question: 'Welche Datenkategorien verarbeitet Ihr Recruiting?',
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer das Bewerbermanagement',
required: false,
options: DEPARTMENT_DATA_CATEGORIES.dept_recruiting.categories.map(c => ({
value: c.id,
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
})),
scoreWeights: { risk: 5, complexity: 3, assurance: 4 },
mapsToVVTQuestion: 'dept_recruiting_categories',
},
{
id: 'dk_dept_finance',
type: 'multi',
question: 'Welche Datenkategorien verarbeitet Ihre Finanzabteilung?',
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Finanzen & Buchhaltung',
required: false,
options: DEPARTMENT_DATA_CATEGORIES.dept_finance.categories.map(c => ({
value: c.id,
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
})),
scoreWeights: { risk: 6, complexity: 4, assurance: 5 },
mapsToVVTQuestion: 'dept_finance_categories',
},
{
id: 'dk_dept_sales',
type: 'multi',
question: 'Welche Datenkategorien verarbeitet Ihr Vertrieb?',
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Vertrieb & CRM',
required: false,
options: DEPARTMENT_DATA_CATEGORIES.dept_sales.categories.map(c => ({
value: c.id,
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
})),
scoreWeights: { risk: 5, complexity: 4, assurance: 4 },
mapsToVVTQuestion: 'dept_sales_categories',
},
{
id: 'dk_dept_marketing',
type: 'multi',
question: 'Welche Datenkategorien verarbeitet Ihr Marketing?',
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Marketing',
required: false,
options: DEPARTMENT_DATA_CATEGORIES.dept_marketing.categories.map(c => ({
value: c.id,
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
})),
scoreWeights: { risk: 6, complexity: 5, assurance: 5 },
mapsToVVTQuestion: 'dept_marketing_categories',
},
{
id: 'dk_dept_support',
type: 'multi',
question: 'Welche Datenkategorien verarbeitet Ihr Kundenservice?',
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Support',
required: false,
options: DEPARTMENT_DATA_CATEGORIES.dept_support.categories.map(c => ({
value: c.id,
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
})),
scoreWeights: { risk: 5, complexity: 3, assurance: 4 },
mapsToVVTQuestion: 'dept_support_categories',
},
],
}
/**
* All question blocks in order
*/
@@ -591,6 +683,7 @@ export const SCOPE_QUESTION_BLOCKS: ScopeQuestionBlock[] = [
BLOCK_6_PRODUCT,
BLOCK_7_AI_SYSTEMS,
BLOCK_8_VVT,
BLOCK_9_DATENKATEGORIEN,
]
/**

View File

@@ -49,7 +49,8 @@ export type ScopeQuestionBlockId =
| 'processes' // Rechte & Prozesse
| 'product' // Produktkontext
| 'ai_systems' // KI-Systeme (aus Profil portiert)
| 'vvt'; // Verarbeitungstaetigkeiten (aus Profil portiert)
| 'vvt' // Verarbeitungstaetigkeiten (aus Profil portiert)
| 'datenkategorien_detail'; // Datenkategorien pro Abteilung (Block 9)
/**
* Eine einzelne Frage im Scope-Profiling