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
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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user