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)
|
* VVT — Verarbeitungsverzeichnis (Art. 30 DSGVO)
|
||||||
*
|
*
|
||||||
* 4 Tabs:
|
* 3 Tabs:
|
||||||
* 1. Verzeichnis (Uebersicht aller Verarbeitungstaetigkeiten)
|
* 1. Verzeichnis (Uebersicht + "Aus Scope generieren")
|
||||||
* 2. Verarbeitung bearbeiten (Detail-Editor)
|
* 2. Verarbeitung bearbeiten (Detail-Editor)
|
||||||
* 3. Generator (Profiling-Fragebogen)
|
* 3. Export & Compliance
|
||||||
* 4. Export & Compliance
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
@@ -32,23 +31,15 @@ import {
|
|||||||
} from '@/lib/sdk/vvt-types'
|
} from '@/lib/sdk/vvt-types'
|
||||||
import type { VVTActivity, VVTOrganizationHeader, BusinessFunction } from '@/lib/sdk/vvt-types'
|
import type { VVTActivity, VVTOrganizationHeader, BusinessFunction } from '@/lib/sdk/vvt-types'
|
||||||
import {
|
import {
|
||||||
PROFILING_STEPS,
|
|
||||||
PROFILING_QUESTIONS,
|
|
||||||
DEPARTMENT_DATA_CATEGORIES,
|
|
||||||
getQuestionsForStep,
|
|
||||||
getStepProgress,
|
|
||||||
getTotalProgress,
|
|
||||||
generateActivities,
|
generateActivities,
|
||||||
|
prefillFromScopeAnswers,
|
||||||
} from '@/lib/sdk/vvt-profiling'
|
} from '@/lib/sdk/vvt-profiling'
|
||||||
import type { ProfilingAnswers } from '@/lib/sdk/vvt-profiling'
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
type Tab = 'verzeichnis' | 'editor' | 'generator' | 'export'
|
type Tab = 'verzeichnis' | 'editor' | 'export'
|
||||||
|
|
||||||
const PROFILING_STORAGE_KEY = 'bp_vvt_profiling'
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// API CLIENT
|
// API CLIENT
|
||||||
@@ -202,24 +193,13 @@ export default function VVTPage() {
|
|||||||
const [tab, setTab] = useState<Tab>('verzeichnis')
|
const [tab, setTab] = useState<Tab>('verzeichnis')
|
||||||
const [activities, setActivities] = useState<VVTActivity[]>([])
|
const [activities, setActivities] = useState<VVTActivity[]>([])
|
||||||
const [orgHeader, setOrgHeader] = useState<VVTOrganizationHeader>(createDefaultOrgHeader())
|
const [orgHeader, setOrgHeader] = useState<VVTOrganizationHeader>(createDefaultOrgHeader())
|
||||||
const [profilingAnswers, setProfilingAnswers] = useState<ProfilingAnswers>({})
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'date' | 'status'>('name')
|
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 [isLoading, setIsLoading] = useState(true)
|
||||||
const [apiError, setApiError] = useState<string | null>(null)
|
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
|
// Load activities + org header from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadFromApi() {
|
async function loadFromApi() {
|
||||||
@@ -242,13 +222,6 @@ export default function VVTPage() {
|
|||||||
loadFromApi()
|
loadFromApi()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const updateProfilingAnswers = useCallback((prof: ProfilingAnswers) => {
|
|
||||||
setProfilingAnswers(prof)
|
|
||||||
try {
|
|
||||||
localStorage.setItem(PROFILING_STORAGE_KEY, JSON.stringify(prof))
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Computed stats
|
// Computed stats
|
||||||
const activeCount = activities.filter(a => a.status === 'APPROVED').length
|
const activeCount = activities.filter(a => a.status === 'APPROVED').length
|
||||||
const draftCount = activities.filter(a => a.status === 'DRAFT').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 }[] = [
|
const tabs: { id: Tab; label: string; count?: number }[] = [
|
||||||
{ id: 'verzeichnis', label: 'Verzeichnis', count: activities.length },
|
{ id: 'verzeichnis', label: 'Verzeichnis', count: activities.length },
|
||||||
{ id: 'editor', label: 'Verarbeitung bearbeiten' },
|
{ id: 'editor', label: 'Verarbeitung bearbeiten' },
|
||||||
{ id: 'generator', label: 'Generator' },
|
|
||||||
{ id: 'export', label: 'Export & Compliance' },
|
{ id: 'export', label: 'Export & Compliance' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -342,6 +314,7 @@ export default function VVTPage() {
|
|||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={setSearchQuery}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
setSortBy={setSortBy}
|
setSortBy={setSortBy}
|
||||||
|
scopeAnswers={state.complianceScope?.answers}
|
||||||
onEdit={(id) => { setEditingId(id); setTab('editor') }}
|
onEdit={(id) => { setEditingId(id); setTab('editor') }}
|
||||||
onNew={async () => {
|
onNew={async () => {
|
||||||
const vvtId = generateVVTId(activities.map(a => a.vvtId))
|
const vvtId = generateVVTId(activities.map(a => a.vvtId))
|
||||||
@@ -365,6 +338,18 @@ export default function VVTPage() {
|
|||||||
console.error(err)
|
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' && (
|
{tab === 'export' && (
|
||||||
<TabExport
|
<TabExport
|
||||||
activities={activities}
|
activities={activities}
|
||||||
@@ -438,7 +397,7 @@ export default function VVTPage() {
|
|||||||
function TabVerzeichnis({
|
function TabVerzeichnis({
|
||||||
activities, allActivities, activeCount, draftCount, thirdCountryCount, art9Count,
|
activities, allActivities, activeCount, draftCount, thirdCountryCount, art9Count,
|
||||||
filter, setFilter, searchQuery, setSearchQuery, sortBy, setSortBy,
|
filter, setFilter, searchQuery, setSearchQuery, sortBy, setSortBy,
|
||||||
onEdit, onNew, onDelete,
|
scopeAnswers, onEdit, onNew, onDelete, onAdoptGenerated,
|
||||||
}: {
|
}: {
|
||||||
activities: VVTActivity[]
|
activities: VVTActivity[]
|
||||||
allActivities: VVTActivity[]
|
allActivities: VVTActivity[]
|
||||||
@@ -452,12 +411,100 @@ function TabVerzeichnis({
|
|||||||
setSearchQuery: (q: string) => void
|
setSearchQuery: (q: string) => void
|
||||||
sortBy: string
|
sortBy: string
|
||||||
setSortBy: (s: 'name' | 'date' | 'status') => void
|
setSortBy: (s: 'name' | 'date' | 'status') => void
|
||||||
|
scopeAnswers?: import('@/lib/sdk/compliance-scope-types').ScopeProfilingAnswer[]
|
||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
onDelete: (id: string) => 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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 */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
<StatCard label="Gesamt" value={allActivities.length} color="gray" />
|
<StatCard label="Gesamt" value={allActivities.length} color="gray" />
|
||||||
@@ -537,7 +584,7 @@ function TabVerzeichnis({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Keine Verarbeitungen gefunden</h3>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1004,314 +1051,7 @@ function TabEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TAB 3: GENERATOR
|
// TAB 3: EXPORT & COMPLIANCE
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
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
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
function TabExport({
|
function TabExport({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
|
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 { 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 type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
|
||||||
@@ -509,19 +510,26 @@ export function ScopeWizardTab({
|
|||||||
|
|
||||||
{/* Questions */}
|
{/* Questions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{currentBlock.questions.map((question) => {
|
{currentBlock.id === 'datenkategorien_detail' ? (
|
||||||
const isAnswered = answers.some(a => a.questionId === question.id)
|
<DatenkategorienBlock9
|
||||||
const borderClass = question.required
|
answers={answers}
|
||||||
? isAnswered
|
onAnswerChange={handleAnswerChange}
|
||||||
? 'border-l-4 border-l-green-400 pl-4'
|
/>
|
||||||
: 'border-l-4 border-l-orange-400 pl-4'
|
) : (
|
||||||
: ''
|
currentBlock.questions.map((question) => {
|
||||||
return (
|
const isAnswered = answers.some(a => a.questionId === question.id)
|
||||||
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
|
const borderClass = question.required
|
||||||
{renderQuestion(question)}
|
? isAnswered
|
||||||
</div>
|
? '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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -561,3 +569,209 @@ export function ScopeWizardTab({
|
|||||||
</div>
|
</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,
|
ComplianceScopeState,
|
||||||
} from './compliance-scope-types'
|
} from './compliance-scope-types'
|
||||||
import type { CompanyProfile } from './types'
|
import type { CompanyProfile } from './types'
|
||||||
|
import { DEPARTMENT_DATA_CATEGORIES } from './vvt-profiling'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block 1: Organisation & Reife
|
* 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
|
* All question blocks in order
|
||||||
*/
|
*/
|
||||||
@@ -591,6 +683,7 @@ export const SCOPE_QUESTION_BLOCKS: ScopeQuestionBlock[] = [
|
|||||||
BLOCK_6_PRODUCT,
|
BLOCK_6_PRODUCT,
|
||||||
BLOCK_7_AI_SYSTEMS,
|
BLOCK_7_AI_SYSTEMS,
|
||||||
BLOCK_8_VVT,
|
BLOCK_8_VVT,
|
||||||
|
BLOCK_9_DATENKATEGORIEN,
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export type ScopeQuestionBlockId =
|
|||||||
| 'processes' // Rechte & Prozesse
|
| 'processes' // Rechte & Prozesse
|
||||||
| 'product' // Produktkontext
|
| 'product' // Produktkontext
|
||||||
| 'ai_systems' // KI-Systeme (aus Profil portiert)
|
| '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
|
* Eine einzelne Frage im Scope-Profiling
|
||||||
|
|||||||
Reference in New Issue
Block a user