fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Database,
|
||||
FileText,
|
||||
List,
|
||||
Table,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
Clock,
|
||||
Users,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
@@ -22,70 +56,79 @@ const PLACEHOLDERS = [
|
||||
{
|
||||
placeholder: '[DATENPUNKTE_TABLE]',
|
||||
label: { de: 'Tabelle', en: 'Table' },
|
||||
description: { de: 'Markdown-Tabelle mit allen Datenpunkten', en: 'Markdown table with all data points' },
|
||||
description: { de: 'Fügt eine Markdown-Tabelle mit allen Datenpunkten ein', en: 'Inserts a markdown table with all data points' },
|
||||
icon: Table,
|
||||
},
|
||||
{
|
||||
placeholder: '[DATENPUNKTE_LIST]',
|
||||
label: { de: 'Liste', en: 'List' },
|
||||
description: { de: 'Kommaseparierte Liste der Namen', en: 'Comma-separated list of names' },
|
||||
description: { de: 'Kommaseparierte Liste der Datenpunkt-Namen', en: 'Comma-separated list of data point names' },
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
placeholder: '[VERARBEITUNGSZWECKE]',
|
||||
label: { de: 'Zwecke', en: 'Purposes' },
|
||||
description: { de: 'Alle Verarbeitungszwecke', en: 'All processing purposes' },
|
||||
description: { de: 'Alle Verarbeitungszwecke (dedupliziert)', en: 'All processing purposes (deduplicated)' },
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
placeholder: '[RECHTSGRUNDLAGEN]',
|
||||
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
|
||||
description: { de: 'DSGVO-Artikel', en: 'GDPR articles' },
|
||||
description: { de: 'Verwendete DSGVO-Artikel', en: 'Used GDPR articles' },
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
placeholder: '[SPEICHERFRISTEN]',
|
||||
label: { de: 'Speicherfristen', en: 'Retention' },
|
||||
description: { de: 'Fristen nach Kategorie', en: 'Periods by category' },
|
||||
description: { de: 'Fristen gruppiert nach Kategorie', en: 'Periods grouped by category' },
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
placeholder: '[EMPFAENGER]',
|
||||
label: { de: 'Empfänger', en: 'Recipients' },
|
||||
description: { de: 'Liste aller Drittparteien', en: 'List of third parties' },
|
||||
description: { de: 'Liste aller Drittparteien', en: 'List of all third parties' },
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
placeholder: '[BESONDERE_KATEGORIEN]',
|
||||
label: { de: 'Art. 9', en: 'Art. 9' },
|
||||
description: { de: 'Abschnitt für sensible Daten', en: 'Section for sensitive data' },
|
||||
label: { de: 'Art. 9 Abschnitt', en: 'Art. 9 Section' },
|
||||
description: { de: 'DSGVO-konformer Abschnitt für sensible Daten', en: 'GDPR-compliant section for sensitive data' },
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
placeholder: '[DRITTLAND_TRANSFERS]',
|
||||
label: { de: 'Drittländer', en: 'Third Countries' },
|
||||
description: { de: 'Datenübermittlung außerhalb EU', en: 'Data transfers outside EU' },
|
||||
description: { de: 'Abschnitt zu Datenübermittlung außerhalb EU', en: 'Section about data transfers outside EU' },
|
||||
icon: Globe,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Risiko-Badge Farben
|
||||
* Risiko-Badge Varianten mapping
|
||||
*/
|
||||
function getRiskBadgeColor(riskLevel: RiskLevel): string {
|
||||
function getRiskBadgeVariant(riskLevel: RiskLevel): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (riskLevel) {
|
||||
case 'HIGH':
|
||||
return 'bg-red-100 text-red-700 border-red-200'
|
||||
return 'destructive'
|
||||
case 'MEDIUM':
|
||||
return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
return 'secondary'
|
||||
case 'LOW':
|
||||
default:
|
||||
return 'bg-green-100 text-green-700 border-green-200'
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DataPointsPreview Komponente
|
||||
*
|
||||
* Zeigt eine Vorschau der ausgewählten Einwilligungen-Datenpunkte im Dokumentengenerator.
|
||||
* Ermöglicht das schnelle Einfügen von Platzhaltern.
|
||||
*/
|
||||
export function DataPointsPreview({
|
||||
dataPoints,
|
||||
onInsertPlaceholder,
|
||||
language = 'de',
|
||||
}: DataPointsPreviewProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
|
||||
|
||||
// Gruppiere Datenpunkte nach Kategorie
|
||||
const byCategory = useMemo(() => {
|
||||
return dataPoints.reduce((acc, dp) => {
|
||||
@@ -101,15 +144,21 @@ export function DataPointsPreview({
|
||||
const stats = useMemo(() => {
|
||||
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
|
||||
let specialCategoryCount = 0
|
||||
let explicitConsentCount = 0
|
||||
const recipients = new Set<string>()
|
||||
|
||||
dataPoints.forEach(dp => {
|
||||
riskCounts[dp.riskLevel]++
|
||||
if (dp.isSpecialCategory) specialCategoryCount++
|
||||
if (dp.requiresExplicitConsent) explicitConsentCount++
|
||||
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
|
||||
})
|
||||
|
||||
return {
|
||||
riskCounts,
|
||||
specialCategoryCount,
|
||||
explicitConsentCount,
|
||||
recipientCount: recipients.size,
|
||||
categoryCount: Object.keys(byCategory).length,
|
||||
}
|
||||
}, [dataPoints, byCategory])
|
||||
@@ -123,173 +172,195 @@ export function DataPointsPreview({
|
||||
})
|
||||
}, [byCategory])
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(category)
|
||||
? prev.filter(c => c !== category)
|
||||
: [...prev, category]
|
||||
)
|
||||
}
|
||||
|
||||
if (dataPoints.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2 mb-3">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{language === 'de'
|
||||
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus.'
|
||||
: 'No data points selected. Select data points in the consent step.'}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{language === 'de'
|
||||
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus, um sie hier zu sehen.'
|
||||
: 'No data points selected. Select data points in the consent step to see them here.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
|
||||
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
|
||||
</p>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* Statistik-Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{stats.riskCounts.HIGH > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
|
||||
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
||||
</span>
|
||||
)}
|
||||
{stats.riskCounts.MEDIUM > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
||||
</span>
|
||||
)}
|
||||
{stats.riskCounts.LOW > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
|
||||
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
||||
</span>
|
||||
)}
|
||||
{stats.specialCategoryCount > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{stats.specialCategoryCount} Art. 9
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="flex-1 flex flex-col gap-4 overflow-hidden">
|
||||
{/* Statistik-Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.riskCounts.HIGH > 0 && (
|
||||
<Badge variant="destructive">
|
||||
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.riskCounts.MEDIUM > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.riskCounts.LOW > 0 && (
|
||||
<Badge variant="outline">
|
||||
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.specialCategoryCount > 0 && (
|
||||
<Badge variant="destructive" className="bg-orange-500 hover:bg-orange-600">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
{stats.specialCategoryCount} Art. 9
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 my-3"></div>
|
||||
<Separator />
|
||||
|
||||
{/* Datenpunkte nach Kategorie */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2 max-h-64">
|
||||
{sortedCategories.map(([category, points]) => {
|
||||
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
||||
if (!metadata) return null
|
||||
const isExpanded = expandedCategories.includes(category)
|
||||
{/* Datenpunkte nach Kategorie */}
|
||||
<ScrollArea className="flex-1 -mr-4 pr-4">
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{sortedCategories.map(([category, points]) => {
|
||||
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
||||
if (!metadata) return null
|
||||
|
||||
return (
|
||||
<div key={category} className="border border-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between p-2 text-sm hover:bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-400">{metadata.code}</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{language === 'de' ? metadata.name.de : metadata.name.en}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">
|
||||
{points.length}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 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>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="px-2 pb-2 space-y-1">
|
||||
{points.map(dp => (
|
||||
<li
|
||||
key={dp.id}
|
||||
className="flex items-center justify-between text-sm py-1 pl-6"
|
||||
>
|
||||
<span className="truncate max-w-[160px] text-gray-700">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
return (
|
||||
<AccordionItem key={category} value={category}>
|
||||
<AccordionTrigger className="text-sm hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{metadata.code}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dp.isSpecialCategory && (
|
||||
<svg className="w-3 h-3 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${getRiskBadgeColor(dp.riskLevel)}`}>
|
||||
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span>
|
||||
{language === 'de' ? metadata.name.de : metadata.name.en}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-auto mr-2">
|
||||
{points.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className="space-y-1 pl-6">
|
||||
{points.map(dp => (
|
||||
<li
|
||||
key={dp.id}
|
||||
className="flex items-center justify-between text-sm py-1"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dp.isSpecialCategory && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertTriangle className="h-3 w-3 text-orange-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{language === 'de'
|
||||
? 'Besondere Kategorie (Art. 9 DSGVO)'
|
||||
: 'Special Category (Art. 9 GDPR)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Badge
|
||||
variant={getRiskBadgeVariant(dp.riskLevel)}
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t border-gray-200 my-3"></div>
|
||||
<Separator />
|
||||
|
||||
{/* Schnell-Einfügen Buttons */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">
|
||||
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => (
|
||||
<button
|
||||
key={placeholder}
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||
title={placeholder}
|
||||
>
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</button>
|
||||
))}
|
||||
{/* Schnell-Einfügen Buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TooltipProvider>
|
||||
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label, description, icon: Icon }) => (
|
||||
<Tooltip key={placeholder}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">
|
||||
{language === 'de' ? description.de : description.en}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||
{placeholder}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TooltipProvider>
|
||||
{PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => (
|
||||
<Tooltip key={placeholder}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">
|
||||
{language === 'de' ? description.de : description.en}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||
{placeholder}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{PLACEHOLDERS.slice(4).map(({ placeholder, label }) => (
|
||||
<button
|
||||
key={placeholder}
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||
title={placeholder}
|
||||
>
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { DataPoint } from '@/lib/sdk/einwilligungen/types'
|
||||
import {
|
||||
validateDocument,
|
||||
@@ -14,6 +30,28 @@ interface DocumentValidationProps {
|
||||
onInsertPlaceholder?: (placeholder: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon für den Warnungstyp
|
||||
*/
|
||||
function getWarningIcon(type: ValidationWarning['type']) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return AlertCircle
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'info':
|
||||
default:
|
||||
return Info
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert-Variante für den Warnungstyp
|
||||
*/
|
||||
function getAlertVariant(type: ValidationWarning['type']): 'default' | 'destructive' {
|
||||
return type === 'error' ? 'destructive' : 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder-Vorschlag aus der Warnung extrahieren
|
||||
*/
|
||||
@@ -24,6 +62,9 @@ function extractPlaceholderSuggestion(warning: ValidationWarning): string | null
|
||||
|
||||
/**
|
||||
* DocumentValidation Komponente
|
||||
*
|
||||
* Zeigt Validierungswarnungen basierend auf ausgewählten Datenpunkten und
|
||||
* dem generierten Dokumentinhalt.
|
||||
*/
|
||||
export function DocumentValidation({
|
||||
dataPoints,
|
||||
@@ -31,8 +72,6 @@ export function DocumentValidation({
|
||||
language = 'de',
|
||||
onInsertPlaceholder,
|
||||
}: DocumentValidationProps) {
|
||||
const [expandedWarnings, setExpandedWarnings] = useState<string[]>([])
|
||||
|
||||
// Führe Validierung durch
|
||||
const warnings = useMemo(() => {
|
||||
if (dataPoints.length === 0 || !documentContent) {
|
||||
@@ -46,33 +85,21 @@ export function DocumentValidation({
|
||||
const warningCount = warnings.filter(w => w.type === 'warning').length
|
||||
const infoCount = warnings.filter(w => w.type === 'info').length
|
||||
|
||||
const toggleWarning = (code: string) => {
|
||||
setExpandedWarnings(prev =>
|
||||
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
|
||||
)
|
||||
}
|
||||
|
||||
if (warnings.length === 0) {
|
||||
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
|
||||
if (dataPoints.length > 0 && documentContent.length > 100) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-green-800">
|
||||
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
||||
</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
{language === 'de'
|
||||
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
|
||||
: 'All necessary sections for the selected data points are present.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900">
|
||||
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertTitle className="text-green-800 dark:text-green-200">
|
||||
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-green-700 dark:text-green-300">
|
||||
{language === 'de'
|
||||
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
|
||||
: 'All necessary sections for the selected data points are present.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -82,126 +109,91 @@ export function DocumentValidation({
|
||||
<div className="space-y-3">
|
||||
{/* Zusammenfassung */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700">
|
||||
<span className="font-medium">
|
||||
{language === 'de' ? 'Validierung:' : 'Validation:'}
|
||||
</span>
|
||||
{errorCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-700">
|
||||
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'}
|
||||
</span>
|
||||
<Badge variant="destructive">
|
||||
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}
|
||||
{errorCount > 1 && (language === 'de' ? '' : 's')}
|
||||
</Badge>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
||||
</span>
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}
|
||||
{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
||||
</Badge>
|
||||
)}
|
||||
{infoCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">
|
||||
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}
|
||||
{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnungen */}
|
||||
{warnings.map((warning, index) => {
|
||||
const Icon = getWarningIcon(warning.type)
|
||||
const placeholder = extractPlaceholderSuggestion(warning)
|
||||
const isExpanded = expandedWarnings.includes(warning.code)
|
||||
const isError = warning.type === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${warning.code}-${index}`}
|
||||
className={`rounded-xl border p-4 ${
|
||||
isError
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className={`w-5 h-5 mt-0.5 ${isError ? 'text-red-600' : 'text-yellow-600'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isError ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Message */}
|
||||
<p className={`font-medium ${isError ? 'text-red-800' : 'text-yellow-800'}`}>
|
||||
{warning.message}
|
||||
</p>
|
||||
|
||||
{/* Suggestion */}
|
||||
<div className="flex items-start gap-2 mt-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">{warning.suggestion}</span>
|
||||
</div>
|
||||
|
||||
{/* Quick-Fix Button */}
|
||||
{placeholder && onInsertPlaceholder && (
|
||||
<button
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
|
||||
<code className="ml-1 text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{placeholder}
|
||||
</code>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Betroffene Datenpunkte */}
|
||||
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => toggleWarning(warning.code)}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 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>
|
||||
{warning.affectedDataPoints.length}{' '}
|
||||
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="mt-2 text-xs space-y-0.5 pl-4">
|
||||
{warning.affectedDataPoints.slice(0, 5).map(dp => (
|
||||
<li key={dp.id} className="list-disc text-gray-600">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</li>
|
||||
))}
|
||||
{warning.affectedDataPoints.length > 5 && (
|
||||
<li className="list-none text-gray-400">
|
||||
... {language === 'de' ? 'und' : 'and'}{' '}
|
||||
{warning.affectedDataPoints.length - 5}{' '}
|
||||
{language === 'de' ? 'weitere' : 'more'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Alert key={`${warning.code}-${index}`} variant={getAlertVariant(warning.type)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<AlertTitle className="flex items-center justify-between">
|
||||
<span>{warning.message}</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2 space-y-2">
|
||||
{/* Vorschlag */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm">{warning.suggestion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick-Fix Button */}
|
||||
{placeholder && onInsertPlaceholder && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
|
||||
<code className="ml-1 text-xs bg-muted px-1 rounded">
|
||||
{placeholder}
|
||||
</code>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Betroffene Datenpunkte */}
|
||||
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
{warning.affectedDataPoints.length}{' '}
|
||||
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<ul className="text-xs space-y-0.5 pl-4">
|
||||
{warning.affectedDataPoints.slice(0, 5).map(dp => (
|
||||
<li key={dp.id} className="list-disc">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</li>
|
||||
))}
|
||||
{warning.affectedDataPoints.length > 5 && (
|
||||
<li className="list-none text-muted-foreground">
|
||||
... {language === 'de' ? 'und' : 'and'}{' '}
|
||||
{warning.affectedDataPoints.length - 5}{' '}
|
||||
{language === 'de' ? 'weitere' : 'more'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
1340
admin-v2/app/(sdk)/sdk/dsfa/[id]/page.tsx
Normal file
1340
admin-v2/app/(sdk)/sdk/dsfa/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,179 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||
import { DSFACard } from '@/components/sdk/dsfa'
|
||||
import {
|
||||
DSFA,
|
||||
DSFAStatus,
|
||||
DSFA_STATUS_LABELS,
|
||||
DSFA_RISK_LEVEL_LABELS,
|
||||
} from '@/lib/sdk/dsfa/types'
|
||||
import {
|
||||
listDSFAs,
|
||||
deleteDSFA,
|
||||
exportDSFAAsJSON,
|
||||
getDSFAStats,
|
||||
createDSFAFromAssessment,
|
||||
getDSFAByAssessment,
|
||||
} from '@/lib/sdk/dsfa/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// UCCA TRIGGER WARNING COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface DSFA {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
approvedBy: string | null
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
processingActivity: string
|
||||
dataCategories: string[]
|
||||
recipients: string[]
|
||||
measures: string[]
|
||||
interface UCCATriggerWarningProps {
|
||||
assessmentId: string
|
||||
triggeredRules: string[]
|
||||
existingDsfaId?: string
|
||||
onCreateDSFA: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockDSFAs: DSFA[] = [
|
||||
{
|
||||
id: 'dsfa-1',
|
||||
title: 'DSFA - Bewerber-Management-System',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer das KI-gestuetzte Bewerber-Screening',
|
||||
status: 'in-review',
|
||||
createdAt: new Date('2024-01-10'),
|
||||
updatedAt: new Date('2024-01-20'),
|
||||
approvedBy: null,
|
||||
riskLevel: 'high',
|
||||
processingActivity: 'Automatisierte Bewertung von Bewerbungsunterlagen',
|
||||
dataCategories: ['Kontaktdaten', 'Beruflicher Werdegang', 'Qualifikationen'],
|
||||
recipients: ['HR-Abteilung', 'Fachabteilungen'],
|
||||
measures: ['Verschluesselung', 'Zugriffskontrolle', 'Menschliche Pruefung'],
|
||||
},
|
||||
{
|
||||
id: 'dsfa-2',
|
||||
title: 'DSFA - Video-Ueberwachung Buero',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer die Videoueberwachung im Buerogebaeude',
|
||||
status: 'approved',
|
||||
createdAt: new Date('2023-11-01'),
|
||||
updatedAt: new Date('2023-12-15'),
|
||||
approvedBy: 'DSB Mueller',
|
||||
riskLevel: 'medium',
|
||||
processingActivity: 'Videoueberwachung zu Sicherheitszwecken',
|
||||
dataCategories: ['Bilddaten', 'Bewegungsdaten'],
|
||||
recipients: ['Sicherheitsdienst'],
|
||||
measures: ['Loeschfristen', 'Zugriffsbeschraenkung', 'Hinweisschilder'],
|
||||
},
|
||||
{
|
||||
id: 'dsfa-3',
|
||||
title: 'DSFA - Kundenanalyse',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer Big-Data-Kundenanalysen',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2024-01-22'),
|
||||
updatedAt: new Date('2024-01-22'),
|
||||
approvedBy: null,
|
||||
riskLevel: 'high',
|
||||
processingActivity: 'Analyse von Kundenverhalten fuer Marketing',
|
||||
dataCategories: ['Kaufhistorie', 'Nutzungsverhalten', 'Praeferenzen'],
|
||||
recipients: ['Marketing', 'Vertrieb'],
|
||||
measures: [],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function DSFACard({ dsfa }: { dsfa: DSFA }) {
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
approved: 'bg-green-100 text-green-700 border-green-200',
|
||||
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
'in-review': 'In Pruefung',
|
||||
approved: 'Genehmigt',
|
||||
'needs-update': 'Aktualisierung erforderlich',
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: 'bg-green-100 text-green-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
function UCCATriggerWarning({
|
||||
assessmentId,
|
||||
triggeredRules,
|
||||
existingDsfaId,
|
||||
onCreateDSFA,
|
||||
}: UCCATriggerWarningProps) {
|
||||
if (existingDsfaId) {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-800">DSFA bereits erstellt</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Fuer dieses Assessment wurde bereits eine DSFA angelegt.
|
||||
</p>
|
||||
<Link
|
||||
href={`/sdk/dsfa/${existingDsfaId}`}
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm text-blue-700 hover:text-blue-800 font-medium"
|
||||
>
|
||||
DSFA oeffnen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
dsfa.status === 'needs-update' ? 'border-orange-200' :
|
||||
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
|
||||
{statusLabels[dsfa.status]}
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4 flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-orange-800">DSFA erforderlich</h4>
|
||||
<p className="text-sm text-orange-600 mt-1">
|
||||
Das UCCA-Assessment hat folgende Trigger ausgeloest:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{triggeredRules.map(rule => (
|
||||
<span key={rule} className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded">
|
||||
{rule}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
|
||||
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
|
||||
dsfa.riskLevel === 'medium' ? 'Mittel' :
|
||||
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{dsfa.dataCategories.map(cat => (
|
||||
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dsfa.measures.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-gray-500">Massnahmen:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{dsfa.measures.map(m => (
|
||||
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Erstellt: {dsfa.createdAt.toLocaleDateString('de-DE')}</span>
|
||||
{dsfa.approvedBy && (
|
||||
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Exportieren
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreateDSFA}
|
||||
className="inline-flex items-center gap-1 mt-3 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
DSFA aus Assessment erstellen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
// =============================================================================
|
||||
// GENERATOR WIZARD COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreated: (dsfa: DSFA) => void }) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
processingPurpose: '',
|
||||
dataCategories: [] as string[],
|
||||
legalBasis: '',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const DATA_CATEGORIES = [
|
||||
'Kontaktdaten',
|
||||
'Identifikationsdaten',
|
||||
'Finanzdaten',
|
||||
'Gesundheitsdaten',
|
||||
'Standortdaten',
|
||||
'Nutzungsdaten',
|
||||
'Biometrische Daten',
|
||||
'Daten Minderjaehriger',
|
||||
]
|
||||
|
||||
const LEGAL_BASES = [
|
||||
{ value: 'consent', label: 'Einwilligung (Art. 6 Abs. 1 lit. a)' },
|
||||
{ value: 'contract', label: 'Vertrag (Art. 6 Abs. 1 lit. b)' },
|
||||
{ value: 'legal_obligation', label: 'Rechtliche Verpflichtung (Art. 6 Abs. 1 lit. c)' },
|
||||
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse (Art. 6 Abs. 1 lit. f)' },
|
||||
]
|
||||
|
||||
const handleCategoryToggle = (cat: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
dataCategories: prev.dataCategories.includes(cat)
|
||||
? prev.dataCategories.filter(c => c !== cat)
|
||||
: [...prev.dataCategories, cat],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// For standalone DSFA, we use the regular create endpoint
|
||||
const response = await fetch('/api/sdk/v1/dsgvo/dsfas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
processing_purpose: formData.processingPurpose,
|
||||
data_categories: formData.dataCategories,
|
||||
legal_basis: formData.legalBasis,
|
||||
status: 'draft',
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const dsfa = await response.json()
|
||||
onCreated(dsfa)
|
||||
onClose()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create DSFA:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Neue Standalone-DSFA erstellen</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -183,7 +181,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
{[1, 2, 3].map(s => (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s < step ? 'bg-green-500 text-white' :
|
||||
@@ -195,7 +193,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
</svg>
|
||||
) : s}
|
||||
</div>
|
||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
{s < 3 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
@@ -205,9 +203,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
@@ -215,21 +215,38 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.processingPurpose}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, processingPurpose: e.target.value }))}
|
||||
placeholder="z.B. Automatisierte Bewerberauswahl"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien *</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
|
||||
{DATA_CATEGORIES.map(cat => (
|
||||
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input type="checkbox" className="w-4 h-4 text-purple-600" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.dataCategories.includes(cat)}
|
||||
onChange={() => handleCategoryToggle(cat)}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm">{cat}</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -240,28 +257,19 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
|
||||
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rechtsgrundlage *</label>
|
||||
<div className="space-y-2">
|
||||
{['Niedrig', 'Mittel', 'Hoch', 'Kritisch'].map(level => (
|
||||
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input type="radio" name="risk" className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm font-medium">{level}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
|
||||
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input type="checkbox" className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm">{m}</span>
|
||||
{LEGAL_BASES.map(basis => (
|
||||
<label key={basis.value} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="legalBasis"
|
||||
value={basis.value}
|
||||
checked={formData.legalBasis === basis.value}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, legalBasis: e.target.value }))}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm font-medium">{basis.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -279,10 +287,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 4 ? setStep(step + 1) : onClose()}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
onClick={() => step < 3 ? setStep(step + 1) : handleSubmit()}
|
||||
disabled={(step === 1 && !formData.name) || (step === 2 && formData.dataCategories.length === 0) || (step === 3 && !formData.legalBasis) || isSubmitting}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{step === 4 ? 'DSFA erstellen' : 'Weiter'}
|
||||
{isSubmitting ? 'Wird erstellt...' : step === 3 ? 'DSFA erstellen' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,28 +305,115 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
export default function DSFAPage() {
|
||||
const router = useRouter()
|
||||
const { state } = useSDK()
|
||||
const [dsfas] = useState<DSFA[]>(mockDSFAs)
|
||||
const [dsfas, setDsfas] = useState<DSFA[]>([])
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
draft: 0,
|
||||
in_review: 0,
|
||||
approved: 0,
|
||||
})
|
||||
|
||||
// Handle uploaded document
|
||||
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
|
||||
console.log('[DSFA Page] Document processed:', doc)
|
||||
}, [])
|
||||
// UCCA trigger info (would come from SDK state)
|
||||
const [uccaTrigger, setUccaTrigger] = useState<{
|
||||
assessmentId: string
|
||||
triggeredRules: string[]
|
||||
existingDsfaId?: string
|
||||
} | null>(null)
|
||||
|
||||
// Open document in workflow editor
|
||||
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
|
||||
router.push(`/compliance/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
|
||||
}, [router])
|
||||
// Load DSFAs
|
||||
const loadDSFAs = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [dsfaList, statsData] = await Promise.all([
|
||||
listDSFAs(filter === 'all' ? undefined : filter),
|
||||
getDSFAStats(),
|
||||
])
|
||||
setDsfas(dsfaList)
|
||||
setStats({
|
||||
total: statsData.total,
|
||||
draft: statsData.status_stats.draft || 0,
|
||||
in_review: statsData.status_stats.in_review || 0,
|
||||
approved: statsData.status_stats.approved || 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load DSFAs:', error)
|
||||
// Set empty state on error
|
||||
setDsfas([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
loadDSFAs()
|
||||
}, [loadDSFAs])
|
||||
|
||||
// Check for UCCA trigger from SDK state
|
||||
// TODO: Enable when UCCA integration is complete
|
||||
// useEffect(() => {
|
||||
// if (state?.uccaAssessment?.dsfa_recommended) {
|
||||
// const assessmentId = state.uccaAssessment.id
|
||||
// const triggeredRules = state.uccaAssessment.triggered_rules
|
||||
// ?.filter((r: { severity: string }) => r.severity === 'BLOCK' || r.severity === 'WARN')
|
||||
// ?.map((r: { code: string }) => r.code) || []
|
||||
//
|
||||
// // Check if DSFA already exists
|
||||
// getDSFAByAssessment(assessmentId).then(existingDsfa => {
|
||||
// setUccaTrigger({
|
||||
// assessmentId,
|
||||
// triggeredRules,
|
||||
// existingDsfaId: existingDsfa?.id,
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
// }, [state?.uccaAssessment])
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Moechten Sie diese DSFA wirklich loeschen?')) {
|
||||
try {
|
||||
await deleteDSFA(id)
|
||||
await loadDSFAs()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete DSFA:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle export
|
||||
const handleExport = async (id: string) => {
|
||||
try {
|
||||
const blob = await exportDSFAAsJSON(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `dsfa_${id.slice(0, 8)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to export DSFA:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create from assessment
|
||||
const handleCreateFromAssessment = async () => {
|
||||
if (!uccaTrigger?.assessmentId) return
|
||||
|
||||
try {
|
||||
const response = await createDSFAFromAssessment(uccaTrigger.assessmentId)
|
||||
router.push(`/sdk/dsfa/${response.dsfa.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to create DSFA from assessment:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDSFAs = filter === 'all'
|
||||
? dsfas
|
||||
: dsfas.filter(d => d.status === filter)
|
||||
|
||||
const draftCount = dsfas.filter(d => d.status === 'draft').length
|
||||
const inReviewCount = dsfas.filter(d => d.status === 'in-review').length
|
||||
const approvedCount = dsfas.filter(d => d.status === 'approved').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['dsfa']
|
||||
|
||||
return (
|
||||
@@ -330,55 +426,67 @@ export default function DSFAPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
{!showGenerator && (
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neue DSFA
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!showGenerator && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Standalone DSFA
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* UCCA Trigger Warning */}
|
||||
{uccaTrigger && (
|
||||
<UCCATriggerWarning
|
||||
assessmentId={uccaTrigger.assessmentId}
|
||||
triggeredRules={uccaTrigger.triggeredRules}
|
||||
existingDsfaId={uccaTrigger.existingDsfaId}
|
||||
onCreateDSFA={handleCreateFromAssessment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Generator */}
|
||||
{showGenerator && (
|
||||
<GeneratorWizard onClose={() => setShowGenerator(false)} />
|
||||
<GeneratorWizard
|
||||
onClose={() => setShowGenerator(false)}
|
||||
onCreated={(dsfa) => {
|
||||
router.push(`/sdk/dsfa/${dsfa.id}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Upload Section */}
|
||||
<DocumentUploadSection
|
||||
documentType="dsfa"
|
||||
onDocumentProcessed={handleDocumentProcessed}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{dsfas.length}</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Entwuerfe</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{draftCount}</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{stats.draft}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">In Pruefung</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{inReviewCount}</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.in_review}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Genehmigt</div>
|
||||
<div className="text-3xl font-bold text-green-600">{approvedCount}</div>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.approved}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'draft', 'in-review', 'approved', 'needs-update'].map(f => (
|
||||
{['all', 'draft', 'in_review', 'approved', 'needs_update'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
@@ -388,22 +496,31 @@ export default function DSFAPage() {
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'draft' ? 'Entwuerfe' :
|
||||
f === 'in-review' ? 'In Pruefung' :
|
||||
f === 'approved' ? 'Genehmigt' : 'Update erforderlich'}
|
||||
{f === 'all' ? 'Alle' : DSFA_STATUS_LABELS[f as DSFAStatus] || f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DSFA List */}
|
||||
<div className="space-y-4">
|
||||
{filteredDSFAs.map(dsfa => (
|
||||
<DSFACard key={dsfa.id} dsfa={dsfa} />
|
||||
))}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredDSFAs.map(dsfa => (
|
||||
<DSFACard
|
||||
key={dsfa.id}
|
||||
dsfa={dsfa}
|
||||
onDelete={handleDelete}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredDSFAs.length === 0 && !showGenerator && (
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredDSFAs.length === 0 && !showGenerator && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -411,13 +528,19 @@ export default function DSFAPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.</p>
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste DSFA erstellen
|
||||
</button>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{filter !== 'all'
|
||||
? `Keine DSFAs mit Status "${DSFA_STATUS_LABELS[filter as DSFAStatus]}".`
|
||||
: 'Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.'}
|
||||
</p>
|
||||
{filter === 'all' && (
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste DSFA erstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
||||
import { CustomerTypeSelector } from '@/components/sdk/CustomerTypeSelector'
|
||||
import type { CustomerType, SDKPackageId } from '@/lib/sdk/types'
|
||||
import { useSDK, getStepsForPhase } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// DASHBOARD CARDS
|
||||
@@ -37,65 +35,49 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
function PackageCard({
|
||||
pkg,
|
||||
function PhaseCard({
|
||||
phase,
|
||||
title,
|
||||
description,
|
||||
completion,
|
||||
stepsCount,
|
||||
isLocked,
|
||||
steps,
|
||||
href,
|
||||
}: {
|
||||
pkg: (typeof SDK_PACKAGES)[number]
|
||||
phase: number
|
||||
title: string
|
||||
description: string
|
||||
completion: number
|
||||
stepsCount: number
|
||||
isLocked: boolean
|
||||
steps: number
|
||||
href: string
|
||||
}) {
|
||||
const steps = getStepsForPackage(pkg.id)
|
||||
const firstStep = steps[0]
|
||||
const href = firstStep?.url || '/sdk'
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`block bg-white rounded-xl border-2 p-6 transition-all ${
|
||||
isLocked
|
||||
? 'border-gray-100 opacity-60 cursor-not-allowed'
|
||||
: completion === 100
|
||||
? 'border-green-200 hover:border-green-300 hover:shadow-lg'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
|
||||
}`}
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
|
||||
isLocked
|
||||
? 'bg-gray-100 text-gray-400'
|
||||
: completion === 100
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center text-xl font-bold ${
|
||||
completion === 100
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
{isLocked ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : completion === 100 ? (
|
||||
{completion === 100 ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
pkg.icon
|
||||
phase
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">{pkg.order}.</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{pkg.description}</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">{stepsCount} Schritte</span>
|
||||
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>
|
||||
{completion}%
|
||||
</span>
|
||||
<span className="text-gray-500">{steps} Schritte</span>
|
||||
<span className="font-medium text-purple-600">{completion}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -106,23 +88,8 @@ function PackageCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<p className="mt-3 text-xs text-gray-400">
|
||||
Ergebnis: {pkg.result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isLocked) {
|
||||
return content
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -162,63 +129,24 @@ function QuickActionCard({
|
||||
// =============================================================================
|
||||
|
||||
export default function SDKDashboard() {
|
||||
const { state, packageCompletion, completionPercentage, setCustomerType } = useSDK()
|
||||
const { state, phase1Completion, phase2Completion, completionPercentage } = useSDK()
|
||||
|
||||
// Calculate total steps
|
||||
const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => {
|
||||
const steps = getStepsForPackage(pkg.id)
|
||||
// Filter import step for new customers
|
||||
return sum + steps.filter(s => !(s.id === 'import' && state.customerType === 'new')).length
|
||||
}, 0)
|
||||
const phase1Steps = getStepsForPhase(1)
|
||||
const phase2Steps = getStepsForPhase(2)
|
||||
|
||||
// Calculate stats
|
||||
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
|
||||
const totalRisks = state.risks.length
|
||||
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH').length
|
||||
|
||||
const isPackageLocked = (packageId: SDKPackageId): boolean => {
|
||||
if (state.preferences?.allowParallelWork) return false
|
||||
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
|
||||
if (!pkg || pkg.order === 1) return false
|
||||
|
||||
// Check if previous package is complete
|
||||
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
|
||||
if (!prevPkg) return false
|
||||
|
||||
return packageCompletion[prevPkg.id] < 100
|
||||
}
|
||||
|
||||
// Show customer type selector if not set
|
||||
if (!state.customerType) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center py-12">
|
||||
<CustomerTypeSelector
|
||||
onSelect={(type: CustomerType) => {
|
||||
setCustomerType(type)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
{state.customerType === 'new'
|
||||
? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.'
|
||||
: 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCustomerType(state.customerType === 'new' ? 'existing' : 'new')}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 underline"
|
||||
>
|
||||
{state.customerType === 'new' ? 'Zu Bestandskunden wechseln' : 'Zu Neukunden wechseln'}
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Willkommen zum Compliance Assessment. Starten Sie mit Phase 1 oder setzen Sie Ihre Arbeit fort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
@@ -226,7 +154,7 @@ export default function SDKDashboard() {
|
||||
<StatCard
|
||||
title="Gesamtfortschritt"
|
||||
value={`${completionPercentage}%`}
|
||||
subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
|
||||
subtitle={`${state.completedSteps.length} von ${phase1Steps.length + phase2Steps.length} Schritten`}
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
@@ -247,7 +175,7 @@ export default function SDKDashboard() {
|
||||
/>
|
||||
<StatCard
|
||||
title="Checkpoints"
|
||||
value={`${completedCheckpoints}/${totalSteps}`}
|
||||
value={`${completedCheckpoints}/${phase1Steps.length + phase2Steps.length}`}
|
||||
subtitle="Validiert"
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -269,85 +197,26 @@ export default function SDKDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bestandskunden: Gap Analysis Banner */}
|
||||
{state.customerType === 'existing' && state.importedDocuments.length === 0 && (
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-2xl">📄</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Bestehende Dokumente importieren</h3>
|
||||
<p className="mt-1 text-gray-600">
|
||||
Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen fuer KI-Compliance erforderlich sind.
|
||||
</p>
|
||||
<Link
|
||||
href="/sdk/import"
|
||||
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Dokumente hochladen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap Analysis Results */}
|
||||
{state.gapAnalysis && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-xl">📊</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{state.gapAnalysis.totalGaps} Luecken gefunden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">{state.gapAnalysis.criticalGaps}</div>
|
||||
<div className="text-xs text-red-600">Kritisch</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-orange-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">{state.gapAnalysis.highGaps}</div>
|
||||
<div className="text-xs text-orange-600">Hoch</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-600">{state.gapAnalysis.mediumGaps}</div>
|
||||
<div className="text-xs text-yellow-600">Mittel</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">{state.gapAnalysis.lowGaps}</div>
|
||||
<div className="text-xs text-green-600">Niedrig</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5 Packages */}
|
||||
{/* Phases */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{SDK_PACKAGES.map(pkg => {
|
||||
const steps = getStepsForPackage(pkg.id)
|
||||
const visibleSteps = steps.filter(s => !(s.id === 'import' && state.customerType === 'new'))
|
||||
|
||||
return (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
completion={packageCompletion[pkg.id]}
|
||||
stepsCount={visibleSteps.length}
|
||||
isLocked={isPackageLocked(pkg.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Phasen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<PhaseCard
|
||||
phase={1}
|
||||
title="Compliance Assessment"
|
||||
description="Use Case erfassen, Screening durchführen, Risiken bewerten"
|
||||
completion={phase1Completion}
|
||||
steps={phase1Steps.length}
|
||||
href="/sdk/advisory-board"
|
||||
/>
|
||||
<PhaseCard
|
||||
phase={2}
|
||||
title="Dokumentengenerierung"
|
||||
description="DSFA, TOMs, VVT, Cookie Banner und mehr generieren"
|
||||
completion={phase2Completion}
|
||||
steps={phase2Steps.length}
|
||||
href="/sdk/ai-act"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -379,7 +248,7 @@ export default function SDKDashboard() {
|
||||
/>
|
||||
<QuickActionCard
|
||||
title="DSFA generieren"
|
||||
description="Datenschutz-Folgenabschaetzung erstellen"
|
||||
description="Datenschutz-Folgenabschätzung erstellen"
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
@@ -405,7 +274,7 @@ export default function SDKDashboard() {
|
||||
{/* Recent Activity */}
|
||||
{state.commandBarHistory.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitaeten</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitäten</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
{state.commandBarHistory.slice(0, 5).map(entry => (
|
||||
<div key={entry.id} className="flex items-center gap-4 px-4 py-3">
|
||||
|
||||
Reference in New Issue
Block a user