+
{currentEntry && (
<>
{/* Row crop */}
{imageNatural.w > 0 && (
{currentEntry.bbox_en.w > 0 && (
0 && (
0 && (
- Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren
+ Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
>
)}
diff --git a/admin-v2/lib/navigation.ts b/admin-v2/lib/navigation.ts
index de74b3f..70146ae 100644
--- a/admin-v2/lib/navigation.ts
+++ b/admin-v2/lib/navigation.ts
@@ -5,7 +5,7 @@
* DSGVO (Datenschutz) and Compliance (Audit & GRC) are now separate
*/
-export type CategoryId = 'dsgvo' | 'compliance' | 'ai' | 'infrastructure' | 'education' | 'communication' | 'development' | 'sdk-docs'
+export type CategoryId = 'dsgvo' | 'compliance' | 'compliance-sdk' | 'ai' | 'infrastructure' | 'education' | 'communication' | 'development' | 'sdk-docs'
export interface NavModule {
id: string
@@ -260,6 +260,27 @@ export const navigation: NavCategory[] = [
],
},
// =========================================================================
+ // Compliance SDK - Datenschutz-Werkzeuge & Kataloge
+ // =========================================================================
+ {
+ id: 'compliance-sdk',
+ name: 'Compliance SDK',
+ icon: 'database',
+ color: '#8b5cf6', // Violet-500
+ colorClass: 'compliance-sdk',
+ description: 'SDK-Kataloge, Risiken & Massnahmen',
+ modules: [
+ {
+ id: 'catalog-manager',
+ name: 'Katalogverwaltung',
+ href: '/dashboard/catalog-manager',
+ description: 'SDK-Kataloge & Auswahltabellen',
+ purpose: 'Zentrale Verwaltung aller Dropdown- und Auswahltabellen im SDK. Systemkataloge (Risiken, Massnahmen, Vorlagen) anzeigen und benutzerdefinierte Eintraege ergaenzen, bearbeiten und loeschen.',
+ audience: ['DSB', 'Compliance Officer', 'Administratoren'],
+ },
+ ],
+ },
+ // =========================================================================
// KI & Automatisierung
// =========================================================================
{
@@ -486,6 +507,15 @@ export const navigation: NavCategory[] = [
audience: ['Lehrer', 'Entwickler'],
oldAdminPath: '/admin/klausur-korrektur',
},
+ {
+ id: 'companion',
+ name: 'Companion',
+ href: '/education/companion',
+ description: 'Unterrichts-Timer & Phasen',
+ purpose: 'Strukturierter Unterricht mit 5-Phasen-Modell (E-A-S-T-R). Visual Timer, Hausaufgaben-Tracking und Reflexion.',
+ audience: ['Lehrer'],
+ oldAdminPath: '/admin/companion',
+ },
],
},
// =========================================================================
diff --git a/admin-v2/lib/sdk/catalog-manager/catalog-registry.ts b/admin-v2/lib/sdk/catalog-manager/catalog-registry.ts
new file mode 100644
index 0000000..70a1702
--- /dev/null
+++ b/admin-v2/lib/sdk/catalog-manager/catalog-registry.ts
@@ -0,0 +1,665 @@
+/**
+ * SDK Catalog Manager - Central Registry
+ *
+ * Maps all SDK catalogs to a unified interface for browsing, searching, and CRUD.
+ */
+
+import type {
+ CatalogId,
+ CatalogMeta,
+ CatalogModule,
+ CatalogEntry,
+ CatalogStats,
+ CatalogOverviewStats,
+ CustomCatalogEntry,
+ CustomCatalogs,
+} from './types'
+
+// =============================================================================
+// CATALOG DATA IMPORTS
+// =============================================================================
+
+import { RISK_CATALOG } from '../dsfa/risk-catalog'
+import { MITIGATION_LIBRARY } from '../dsfa/mitigation-library'
+import { AI_RISK_CATALOG } from '../dsfa/ai-risk-catalog'
+import { AI_MITIGATION_LIBRARY } from '../dsfa/ai-mitigation-library'
+import { PROHIBITED_AI_PRACTICES } from '../dsfa/prohibited-ai-practices'
+import { EU_BASE_FRAMEWORKS, NATIONAL_FRAMEWORKS } from '../dsfa/eu-legal-frameworks'
+import { GDPR_ENFORCEMENT_CASES } from '../dsfa/gdpr-enforcement-cases'
+import { WP248_CRITERIA, SDM_GOALS, DSFA_AUTHORITY_RESOURCES } from '../dsfa/types'
+import { VVT_BASELINE_CATALOG } from '../vvt-baseline-catalog'
+import { BASELINE_TEMPLATES } from '../loeschfristen-baseline-catalog'
+import { VENDOR_TEMPLATES, COUNTRY_RISK_PROFILES } from '../vendor-compliance/catalog/vendor-templates'
+import { LEGAL_BASIS_INFO, STANDARD_RETENTION_PERIODS } from '../vendor-compliance/catalog/legal-basis'
+
+// =============================================================================
+// HELPER: Resolve localized text fields
+// =============================================================================
+
+function resolveField(value: unknown): string {
+ if (value === null || value === undefined) return ''
+ if (typeof value === 'string') return value
+ if (typeof value === 'number') return String(value)
+ if (typeof value === 'object' && 'de' in (value as Record
)) {
+ return String((value as Record).de || '')
+ }
+ return String(value)
+}
+
+// =============================================================================
+// SDM_GOALS as entries array (it's a Record, not an array)
+// =============================================================================
+
+const SDM_GOALS_ENTRIES = Object.entries(SDM_GOALS).map(([key, val]) => ({
+ id: key,
+ name: val.name,
+ description: val.description,
+ article: val.article,
+}))
+
+// =============================================================================
+// CATALOG REGISTRY
+// =============================================================================
+
+export const CATALOG_REGISTRY: Record = {
+ 'dsfa-risks': {
+ id: 'dsfa-risks',
+ name: 'DSFA Risikokatalog',
+ description: 'Standardrisiken fuer Datenschutz-Folgenabschaetzungen',
+ module: 'dsfa',
+ icon: 'ShieldAlert',
+ systemCount: RISK_CATALOG.length,
+ allowCustom: true,
+ idField: 'id',
+ nameField: 'title',
+ descriptionField: 'description',
+ categoryField: 'category',
+ fields: [
+ { key: 'id', label: 'Risiko-ID', type: 'text', required: true, placeholder: 'R-XXX-01' },
+ { key: 'title', label: 'Titel', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'category', label: 'Kategorie', type: 'select', required: true, options: [
+ { value: 'confidentiality', label: 'Vertraulichkeit' },
+ { value: 'integrity', label: 'Integritaet' },
+ { value: 'availability', label: 'Verfuegbarkeit' },
+ { value: 'rights_freedoms', label: 'Rechte & Freiheiten' },
+ ]},
+ { key: 'typicalLikelihood', label: 'Typische Eintrittswahrscheinlichkeit', type: 'select', required: false, options: [
+ { value: 'low', label: 'Niedrig' },
+ { value: 'medium', label: 'Mittel' },
+ { value: 'high', label: 'Hoch' },
+ ]},
+ { key: 'typicalImpact', label: 'Typische Auswirkung', type: 'select', required: false, options: [
+ { value: 'low', label: 'Niedrig' },
+ { value: 'medium', label: 'Mittel' },
+ { value: 'high', label: 'Hoch' },
+ ]},
+ ],
+ searchableFields: ['id', 'title', 'description', 'category'],
+ },
+
+ 'dsfa-mitigations': {
+ id: 'dsfa-mitigations',
+ name: 'DSFA Massnahmenbibliothek',
+ description: 'Technische und organisatorische Massnahmen fuer DSFAs',
+ module: 'dsfa',
+ icon: 'Shield',
+ systemCount: MITIGATION_LIBRARY.length,
+ allowCustom: true,
+ idField: 'id',
+ nameField: 'title',
+ descriptionField: 'description',
+ categoryField: 'type',
+ fields: [
+ { key: 'id', label: 'Massnahmen-ID', type: 'text', required: true, placeholder: 'M-XXX-01' },
+ { key: 'title', label: 'Titel', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'type', label: 'Typ', type: 'select', required: true, options: [
+ { value: 'technical', label: 'Technisch' },
+ { value: 'organizational', label: 'Organisatorisch' },
+ { value: 'legal', label: 'Rechtlich' },
+ ]},
+ { key: 'effectiveness', label: 'Wirksamkeit', type: 'select', required: false, options: [
+ { value: 'low', label: 'Niedrig' },
+ { value: 'medium', label: 'Mittel' },
+ { value: 'high', label: 'Hoch' },
+ ]},
+ { key: 'legalBasis', label: 'Rechtsgrundlage', type: 'text', required: false },
+ ],
+ searchableFields: ['id', 'title', 'description', 'type', 'legalBasis'],
+ },
+
+ 'ai-risks': {
+ id: 'ai-risks',
+ name: 'KI-Risikokatalog',
+ description: 'Spezifische Risiken fuer KI-Systeme',
+ module: 'ai_act',
+ icon: 'Bot',
+ systemCount: AI_RISK_CATALOG.length,
+ allowCustom: true,
+ idField: 'id',
+ nameField: 'title',
+ descriptionField: 'description',
+ categoryField: 'category',
+ fields: [
+ { key: 'id', label: 'Risiko-ID', type: 'text', required: true, placeholder: 'R-AI-XXX-01' },
+ { key: 'title', label: 'Titel', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'category', label: 'Kategorie', type: 'select', required: true, options: [
+ { value: 'confidentiality', label: 'Vertraulichkeit' },
+ { value: 'integrity', label: 'Integritaet' },
+ { value: 'availability', label: 'Verfuegbarkeit' },
+ { value: 'rights_freedoms', label: 'Rechte & Freiheiten' },
+ ]},
+ { key: 'typicalLikelihood', label: 'Eintrittswahrscheinlichkeit', type: 'select', required: false, options: [
+ { value: 'low', label: 'Niedrig' },
+ { value: 'medium', label: 'Mittel' },
+ { value: 'high', label: 'Hoch' },
+ ]},
+ { key: 'typicalImpact', label: 'Auswirkung', type: 'select', required: false, options: [
+ { value: 'low', label: 'Niedrig' },
+ { value: 'medium', label: 'Mittel' },
+ { value: 'high', label: 'Hoch' },
+ ]},
+ ],
+ searchableFields: ['id', 'title', 'description', 'category'],
+ },
+
+ 'ai-mitigations': {
+ id: 'ai-mitigations',
+ name: 'KI-Massnahmenbibliothek',
+ description: 'Massnahmen fuer KI-spezifische Risiken',
+ module: 'ai_act',
+ icon: 'ShieldCheck',
+ systemCount: AI_MITIGATION_LIBRARY.length,
+ allowCustom: true,
+ idField: 'id',
+ nameField: 'title',
+ descriptionField: 'description',
+ categoryField: 'type',
+ fields: [
+ { key: 'id', label: 'Massnahmen-ID', type: 'text', required: true },
+ { key: 'title', label: 'Titel', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'type', label: 'Typ', type: 'select', required: true, options: [
+ { value: 'technical', label: 'Technisch' },
+ { value: 'organizational', label: 'Organisatorisch' },
+ { value: 'legal', label: 'Rechtlich' },
+ ]},
+ { key: 'effectiveness', label: 'Wirksamkeit', type: 'select', required: false, options: [
+ { value: 'low', label: 'Niedrig' },
+ { value: 'medium', label: 'Mittel' },
+ { value: 'high', label: 'Hoch' },
+ ]},
+ ],
+ searchableFields: ['id', 'title', 'description', 'type'],
+ },
+
+ 'prohibited-ai-practices': {
+ id: 'prohibited-ai-practices',
+ name: 'Verbotene KI-Praktiken',
+ description: 'Absolut und bedingt verbotene KI-Anwendungen nach AI Act',
+ module: 'ai_act',
+ icon: 'Ban',
+ systemCount: PROHIBITED_AI_PRACTICES.length,
+ allowCustom: false,
+ idField: 'id',
+ nameField: 'title',
+ descriptionField: 'description',
+ categoryField: 'severity',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'title', label: 'Titel', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'severity', label: 'Schwere', type: 'select', required: true, options: [
+ { value: 'absolute', label: 'Absolutes Verbot' },
+ { value: 'conditional', label: 'Bedingtes Verbot' },
+ ]},
+ { key: 'legalBasis', label: 'Rechtsgrundlage', type: 'text', required: false },
+ ],
+ searchableFields: ['id', 'title', 'description', 'severity', 'legalBasis'],
+ },
+
+ 'vvt-templates': {
+ id: 'vvt-templates',
+ name: 'VVT Baseline-Vorlagen',
+ description: 'Vorlagen fuer Verarbeitungstaetigkeiten',
+ module: 'vvt',
+ icon: 'FileText',
+ systemCount: VVT_BASELINE_CATALOG.length,
+ allowCustom: true,
+ idField: 'templateId',
+ nameField: 'name',
+ descriptionField: 'description',
+ categoryField: 'businessFunction',
+ fields: [
+ { key: 'templateId', label: 'Vorlagen-ID', type: 'text', required: true },
+ { key: 'name', label: 'Name', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'businessFunction', label: 'Geschaeftsbereich', type: 'select', required: true, options: [
+ { value: 'hr', label: 'Personal' },
+ { value: 'finance', label: 'Finanzen' },
+ { value: 'sales', label: 'Vertrieb' },
+ { value: 'marketing', label: 'Marketing' },
+ { value: 'support', label: 'Support' },
+ { value: 'it', label: 'IT' },
+ { value: 'other', label: 'Sonstige' },
+ ]},
+ { key: 'protectionLevel', label: 'Schutzniveau', type: 'select', required: false, options: [
+ { value: 'LOW', label: 'Niedrig' },
+ { value: 'MEDIUM', label: 'Mittel' },
+ { value: 'HIGH', label: 'Hoch' },
+ ]},
+ ],
+ searchableFields: ['templateId', 'name', 'description', 'businessFunction'],
+ },
+
+ 'loeschfristen-templates': {
+ id: 'loeschfristen-templates',
+ name: 'Loeschfristen-Vorlagen',
+ description: 'Baseline-Vorlagen fuer Aufbewahrungsfristen',
+ module: 'vvt',
+ icon: 'Clock',
+ systemCount: BASELINE_TEMPLATES.length,
+ allowCustom: true,
+ idField: 'templateId',
+ nameField: 'dataObjectName',
+ descriptionField: 'description',
+ categoryField: 'retentionDriver',
+ fields: [
+ { key: 'templateId', label: 'Vorlagen-ID', type: 'text', required: true },
+ { key: 'dataObjectName', label: 'Datenobjekt', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'retentionDuration', label: 'Aufbewahrungsdauer', type: 'number', required: true, min: 0 },
+ { key: 'retentionUnit', label: 'Einheit', type: 'select', required: true, options: [
+ { value: 'days', label: 'Tage' },
+ { value: 'months', label: 'Monate' },
+ { value: 'years', label: 'Jahre' },
+ ]},
+ { key: 'deletionMethod', label: 'Loeschmethode', type: 'text', required: false },
+ { key: 'responsibleRole', label: 'Verantwortlich', type: 'text', required: false },
+ ],
+ searchableFields: ['templateId', 'dataObjectName', 'description', 'retentionDriver'],
+ },
+
+ 'vendor-templates': {
+ id: 'vendor-templates',
+ name: 'AV-Vorlagen',
+ description: 'Vorlagen fuer Auftragsverarbeitungsvertraege',
+ module: 'vendor',
+ icon: 'Building2',
+ systemCount: VENDOR_TEMPLATES.length,
+ allowCustom: true,
+ idField: 'id',
+ nameField: 'name',
+ descriptionField: 'description',
+ categoryField: 'serviceCategory',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'name', label: 'Name', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'serviceCategory', label: 'Kategorie', type: 'text', required: true },
+ ],
+ searchableFields: ['id', 'name', 'description', 'serviceCategory'],
+ },
+
+ 'country-risk-profiles': {
+ id: 'country-risk-profiles',
+ name: 'Laenderrisikoprofile',
+ description: 'Datenschutz-Risikobewertung nach Laendern',
+ module: 'vendor',
+ icon: 'Globe',
+ systemCount: COUNTRY_RISK_PROFILES.length,
+ allowCustom: false,
+ idField: 'code',
+ nameField: 'name',
+ categoryField: 'riskLevel',
+ fields: [
+ { key: 'code', label: 'Laendercode', type: 'text', required: true },
+ { key: 'name', label: 'Land', type: 'text', required: true },
+ { key: 'riskLevel', label: 'Risikostufe', type: 'select', required: true, options: [
+ { value: 'LOW', label: 'Niedrig' },
+ { value: 'MEDIUM', label: 'Mittel' },
+ { value: 'HIGH', label: 'Hoch' },
+ { value: 'VERY_HIGH', label: 'Sehr hoch' },
+ ]},
+ { key: 'isEU', label: 'EU-Mitglied', type: 'boolean', required: false },
+ { key: 'isEEA', label: 'EWR-Mitglied', type: 'boolean', required: false },
+ { key: 'hasAdequacyDecision', label: 'Angemessenheitsbeschluss', type: 'boolean', required: false },
+ ],
+ searchableFields: ['code', 'name', 'riskLevel'],
+ },
+
+ 'legal-bases': {
+ id: 'legal-bases',
+ name: 'Rechtsgrundlagen',
+ description: 'DSGVO Art. 6 und Art. 9 Rechtsgrundlagen',
+ module: 'reference',
+ icon: 'Scale',
+ systemCount: LEGAL_BASIS_INFO.length,
+ allowCustom: false,
+ idField: 'type',
+ nameField: 'name',
+ descriptionField: 'description',
+ categoryField: 'article',
+ fields: [
+ { key: 'type', label: 'Typ', type: 'text', required: true },
+ { key: 'article', label: 'Artikel', type: 'text', required: true },
+ { key: 'name', label: 'Name', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'isSpecialCategory', label: 'Besondere Kategorie (Art. 9)', type: 'boolean', required: false },
+ ],
+ searchableFields: ['type', 'article', 'name', 'description'],
+ },
+
+ 'retention-periods': {
+ id: 'retention-periods',
+ name: 'Aufbewahrungsfristen',
+ description: 'Gesetzliche Standard-Aufbewahrungsfristen',
+ module: 'reference',
+ icon: 'Timer',
+ systemCount: STANDARD_RETENTION_PERIODS.length,
+ allowCustom: false,
+ idField: 'id',
+ nameField: 'name',
+ descriptionField: 'description',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'name', label: 'Name', type: 'text', required: true },
+ { key: 'legalBasis', label: 'Rechtsgrundlage', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ ],
+ searchableFields: ['id', 'name', 'legalBasis', 'description'],
+ },
+
+ 'eu-legal-frameworks': {
+ id: 'eu-legal-frameworks',
+ name: 'EU-Rechtsrahmen',
+ description: 'EU-weite Datenschutzgesetze und -verordnungen',
+ module: 'reference',
+ icon: 'Landmark',
+ systemCount: EU_BASE_FRAMEWORKS.length,
+ allowCustom: false,
+ idField: 'id',
+ nameField: 'name',
+ descriptionField: 'description',
+ categoryField: 'type',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'name', label: 'Name', type: 'text', required: true },
+ { key: 'fullName', label: 'Vollstaendiger Name', type: 'text', required: true },
+ { key: 'type', label: 'Typ', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ ],
+ searchableFields: ['id', 'name', 'fullName', 'description', 'type'],
+ },
+
+ 'national-legal-frameworks': {
+ id: 'national-legal-frameworks',
+ name: 'Nationale Rechtsrahmen',
+ description: 'Nationale Datenschutzgesetze der EU/EWR-Staaten',
+ module: 'reference',
+ icon: 'Flag',
+ systemCount: NATIONAL_FRAMEWORKS.length,
+ allowCustom: false,
+ idField: 'id',
+ nameField: 'name',
+ descriptionField: 'description',
+ categoryField: 'countryCode',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'name', label: 'Name', type: 'text', required: true },
+ { key: 'countryCode', label: 'Land', type: 'text', required: true },
+ { key: 'type', label: 'Typ', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ ],
+ searchableFields: ['id', 'name', 'countryCode', 'description', 'type'],
+ },
+
+ 'gdpr-enforcement-cases': {
+ id: 'gdpr-enforcement-cases',
+ name: 'DSGVO-Bussgeldentscheidungen',
+ description: 'Relevante Bussgeldentscheidungen als Referenz',
+ module: 'reference',
+ icon: 'Gavel',
+ systemCount: GDPR_ENFORCEMENT_CASES.length,
+ allowCustom: false,
+ idField: 'id',
+ nameField: 'company',
+ descriptionField: 'description',
+ categoryField: 'country',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'company', label: 'Unternehmen', type: 'text', required: true },
+ { key: 'country', label: 'Land', type: 'text', required: true },
+ { key: 'year', label: 'Jahr', type: 'number', required: true },
+ { key: 'fineOriginal', label: 'Bussgeld (EUR)', type: 'number', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ ],
+ searchableFields: ['id', 'company', 'country', 'description'],
+ },
+
+ 'wp248-criteria': {
+ id: 'wp248-criteria',
+ name: 'WP248 Kriterien',
+ description: 'Kriterien zur DSFA-Pflichtpruefung nach WP248',
+ module: 'dsfa',
+ icon: 'ClipboardCheck',
+ systemCount: WP248_CRITERIA.length,
+ allowCustom: false,
+ idField: 'code',
+ nameField: 'title',
+ descriptionField: 'description',
+ fields: [
+ { key: 'code', label: 'Code', type: 'text', required: true },
+ { key: 'title', label: 'Titel', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'gdprRef', label: 'DSGVO-Referenz', type: 'text', required: false },
+ ],
+ searchableFields: ['code', 'title', 'description', 'gdprRef'],
+ },
+
+ 'sdm-goals': {
+ id: 'sdm-goals',
+ name: 'SDM Gewaehrleistungsziele',
+ description: 'Standard-Datenschutzmodell Gewaehrleistungsziele',
+ module: 'dsfa',
+ icon: 'Target',
+ systemCount: SDM_GOALS_ENTRIES.length,
+ allowCustom: false,
+ idField: 'id',
+ nameField: 'name',
+ descriptionField: 'description',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'name', label: 'Name', type: 'text', required: true },
+ { key: 'description', label: 'Beschreibung', type: 'textarea', required: true },
+ { key: 'article', label: 'DSGVO-Artikel', type: 'text', required: false },
+ ],
+ searchableFields: ['id', 'name', 'description', 'article'],
+ },
+
+ 'dsfa-authority-resources': {
+ id: 'dsfa-authority-resources',
+ name: 'Aufsichtsbehoerden-Ressourcen',
+ description: 'DSFA-Ressourcen der deutschen Aufsichtsbehoerden',
+ module: 'dsfa',
+ icon: 'Building',
+ systemCount: DSFA_AUTHORITY_RESOURCES.length,
+ allowCustom: false,
+ idField: 'id',
+ nameField: 'shortName',
+ descriptionField: 'name',
+ categoryField: 'state',
+ fields: [
+ { key: 'id', label: 'ID', type: 'text', required: true },
+ { key: 'shortName', label: 'Kurzname', type: 'text', required: true },
+ { key: 'name', label: 'Voller Name', type: 'text', required: true },
+ { key: 'state', label: 'Bundesland', type: 'text', required: true },
+ ],
+ searchableFields: ['id', 'shortName', 'name', 'state'],
+ },
+}
+
+// =============================================================================
+// SYSTEM ENTRIES MAP
+// =============================================================================
+
+const SYSTEM_ENTRIES_MAP: Record[]> = {
+ 'dsfa-risks': RISK_CATALOG as unknown as Record[],
+ 'dsfa-mitigations': MITIGATION_LIBRARY as unknown as Record[],
+ 'ai-risks': AI_RISK_CATALOG as unknown as Record[],
+ 'ai-mitigations': AI_MITIGATION_LIBRARY as unknown as Record[],
+ 'prohibited-ai-practices': PROHIBITED_AI_PRACTICES as unknown as Record[],
+ 'vvt-templates': VVT_BASELINE_CATALOG as unknown as Record[],
+ 'loeschfristen-templates': BASELINE_TEMPLATES as unknown as Record[],
+ 'vendor-templates': VENDOR_TEMPLATES as unknown as Record[],
+ 'country-risk-profiles': COUNTRY_RISK_PROFILES as unknown as Record[],
+ 'legal-bases': LEGAL_BASIS_INFO as unknown as Record[],
+ 'retention-periods': STANDARD_RETENTION_PERIODS as unknown as Record[],
+ 'eu-legal-frameworks': EU_BASE_FRAMEWORKS as unknown as Record[],
+ 'national-legal-frameworks': NATIONAL_FRAMEWORKS as unknown as Record[],
+ 'gdpr-enforcement-cases': GDPR_ENFORCEMENT_CASES as unknown as Record[],
+ 'wp248-criteria': WP248_CRITERIA as unknown as Record[],
+ 'sdm-goals': SDM_GOALS_ENTRIES as unknown as Record[],
+ 'dsfa-authority-resources': DSFA_AUTHORITY_RESOURCES as unknown as Record[],
+}
+
+// =============================================================================
+// ENTRY CONVERTERS
+// =============================================================================
+
+function systemEntryToCatalogEntry(
+ catalogId: CatalogId,
+ data: Record,
+): CatalogEntry {
+ const meta = CATALOG_REGISTRY[catalogId]
+ const idValue = resolveField(data[meta.idField])
+ const nameValue = resolveField(data[meta.nameField])
+ const descValue = meta.descriptionField ? resolveField(data[meta.descriptionField]) : undefined
+ const catValue = meta.categoryField ? resolveField(data[meta.categoryField]) : undefined
+
+ return {
+ id: idValue || crypto.randomUUID(),
+ catalogId,
+ source: 'system',
+ data,
+ displayName: nameValue || idValue || '(Unbenannt)',
+ displayDescription: descValue,
+ category: catValue,
+ }
+}
+
+function customEntryToCatalogEntry(
+ catalogId: CatalogId,
+ entry: CustomCatalogEntry,
+): CatalogEntry {
+ const meta = CATALOG_REGISTRY[catalogId]
+ const nameValue = resolveField(entry.data[meta.nameField])
+ const descValue = meta.descriptionField ? resolveField(entry.data[meta.descriptionField]) : undefined
+ const catValue = meta.categoryField ? resolveField(entry.data[meta.categoryField]) : undefined
+
+ return {
+ id: entry.id,
+ catalogId,
+ source: 'custom',
+ data: entry.data,
+ displayName: nameValue || '(Unbenannt)',
+ displayDescription: descValue,
+ category: catValue,
+ }
+}
+
+// =============================================================================
+// PUBLIC API
+// =============================================================================
+
+export function getSystemEntries(catalogId: CatalogId): CatalogEntry[] {
+ const raw = SYSTEM_ENTRIES_MAP[catalogId] || []
+ return raw.map(data => systemEntryToCatalogEntry(catalogId, data))
+}
+
+export function getAllEntries(
+ catalogId: CatalogId,
+ customEntries: CustomCatalogEntry[] = [],
+): CatalogEntry[] {
+ const system = getSystemEntries(catalogId)
+ const custom = customEntries.map(e => customEntryToCatalogEntry(catalogId, e))
+ return [...system, ...custom]
+}
+
+export function getCatalogsByModule(module: CatalogModule): CatalogMeta[] {
+ return Object.values(CATALOG_REGISTRY).filter(c => c.module === module)
+}
+
+export function getCatalogStats(
+ catalogId: CatalogId,
+ customEntries: CustomCatalogEntry[] = [],
+): CatalogStats {
+ const meta = CATALOG_REGISTRY[catalogId]
+ return {
+ catalogId,
+ systemCount: meta.systemCount,
+ customCount: customEntries.length,
+ totalCount: meta.systemCount + customEntries.length,
+ }
+}
+
+export function getOverviewStats(customCatalogs: CustomCatalogs): CatalogOverviewStats {
+ const modules: CatalogModule[] = ['dsfa', 'vvt', 'vendor', 'ai_act', 'reference']
+ const byModule = {} as Record
+
+ let totalSystemEntries = 0
+ let totalCustomEntries = 0
+
+ for (const mod of modules) {
+ const cats = getCatalogsByModule(mod)
+ let entries = 0
+ for (const cat of cats) {
+ const customCount = customCatalogs[cat.id]?.length ?? 0
+ entries += cat.systemCount + customCount
+ totalSystemEntries += cat.systemCount
+ totalCustomEntries += customCount
+ }
+ byModule[mod] = { catalogs: cats.length, entries }
+ }
+
+ return {
+ totalCatalogs: Object.keys(CATALOG_REGISTRY).length,
+ totalSystemEntries,
+ totalCustomEntries,
+ totalEntries: totalSystemEntries + totalCustomEntries,
+ byModule,
+ }
+}
+
+export function searchCatalog(
+ catalogId: CatalogId,
+ query: string,
+ customEntries: CustomCatalogEntry[] = [],
+): CatalogEntry[] {
+ const allEntries = getAllEntries(catalogId, customEntries)
+ const meta = CATALOG_REGISTRY[catalogId]
+ const q = query.toLowerCase().trim()
+
+ if (!q) return allEntries
+
+ return allEntries
+ .map(entry => {
+ let score = 0
+ for (const field of meta.searchableFields) {
+ const value = resolveField(entry.data[field]).toLowerCase()
+ if (value.includes(q)) {
+ score += value.startsWith(q) ? 10 : 5
+ }
+ }
+ // Also search display name
+ if (entry.displayName.toLowerCase().includes(q)) {
+ score += 15
+ }
+ return { entry, score }
+ })
+ .filter(r => r.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .map(r => r.entry)
+}
diff --git a/admin-v2/lib/sdk/catalog-manager/types.ts b/admin-v2/lib/sdk/catalog-manager/types.ts
new file mode 100644
index 0000000..540f2d0
--- /dev/null
+++ b/admin-v2/lib/sdk/catalog-manager/types.ts
@@ -0,0 +1,118 @@
+/**
+ * SDK Catalog Manager - Type Definitions
+ */
+
+// All catalog IDs in the system
+export type CatalogId =
+ | 'dsfa-risks'
+ | 'dsfa-mitigations'
+ | 'ai-risks'
+ | 'ai-mitigations'
+ | 'prohibited-ai-practices'
+ | 'vvt-templates'
+ | 'loeschfristen-templates'
+ | 'vendor-templates'
+ | 'country-risk-profiles'
+ | 'legal-bases'
+ | 'retention-periods'
+ | 'eu-legal-frameworks'
+ | 'national-legal-frameworks'
+ | 'gdpr-enforcement-cases'
+ | 'wp248-criteria'
+ | 'sdm-goals'
+ | 'dsfa-authority-resources'
+
+// Module grouping
+export type CatalogModule = 'dsfa' | 'vvt' | 'vendor' | 'ai_act' | 'reference'
+
+// Field types for dynamic forms
+export type CatalogFieldType = 'text' | 'textarea' | 'number' | 'select' | 'multiselect' | 'boolean' | 'tags'
+
+export interface CatalogFieldSchema {
+ key: string
+ label: string
+ type: CatalogFieldType
+ required: boolean
+ placeholder?: string
+ description?: string
+ options?: { value: string; label: string }[]
+ helpText?: string
+ min?: number
+ max?: number
+ step?: number
+}
+
+export interface CatalogMeta {
+ id: CatalogId
+ name: string
+ description: string
+ module: CatalogModule
+ icon: string // lucide icon name
+ systemCount: number
+ allowCustom: boolean
+ idField: string // which field is the unique ID (e.g. 'id', 'templateId', 'code')
+ nameField: string // which field is the display name (e.g. 'title', 'name', 'dataObjectName')
+ descriptionField?: string // which field holds description
+ categoryField?: string // optional grouping field
+ fields: CatalogFieldSchema[]
+ searchableFields: string[]
+}
+
+// A custom catalog entry added by the user
+export interface CustomCatalogEntry {
+ id: string // Generated UUID
+ catalogId: CatalogId
+ data: Record
+ createdAt: string // ISO date
+ updatedAt: string // ISO date
+ createdBy?: string
+}
+
+// All custom entries, keyed by CatalogId
+export type CustomCatalogs = Partial>
+
+// Combined view entry (system or custom)
+export interface CatalogEntry {
+ id: string
+ catalogId: CatalogId
+ source: 'system' | 'custom'
+ data: Record
+ displayName: string
+ displayDescription?: string
+ category?: string
+}
+
+// Stats for a single catalog
+export interface CatalogStats {
+ catalogId: CatalogId
+ systemCount: number
+ customCount: number
+ totalCount: number
+}
+
+// Stats for all catalogs
+export interface CatalogOverviewStats {
+ totalCatalogs: number
+ totalSystemEntries: number
+ totalCustomEntries: number
+ totalEntries: number
+ byModule: Record
+}
+
+// Module labels
+export const CATALOG_MODULE_LABELS: Record = {
+ dsfa: 'DSFA & Risiken',
+ vvt: 'VVT & Loeschfristen',
+ vendor: 'Auftragsverarbeitung',
+ ai_act: 'AI Act',
+ reference: 'Referenzdaten',
+}
+
+// Module icons (lucide names)
+export const CATALOG_MODULE_ICONS: Record = {
+ dsfa: 'ShieldAlert',
+ vvt: 'FileText',
+ vendor: 'Building2',
+ ai_act: 'Bot',
+ reference: 'BookOpen',
+}
diff --git a/klausur-service/backend/cv_vocab_pipeline.py b/klausur-service/backend/cv_vocab_pipeline.py
index 8ef1304..75af8b4 100644
--- a/klausur-service/backend/cv_vocab_pipeline.py
+++ b/klausur-service/backend/cv_vocab_pipeline.py
@@ -193,6 +193,127 @@ def deskew_image(img: np.ndarray) -> Tuple[np.ndarray, float]:
return corrected, median_angle
+def deskew_image_by_word_alignment(
+ image_data: bytes,
+ lang: str = "eng+deu",
+ downscale_factor: float = 0.5,
+) -> Tuple[bytes, float]:
+ """Correct rotation by fitting a line through left-most word starts per text line.
+
+ More robust than Hough-based deskew for vocabulary worksheets where text lines
+ have consistent left-alignment. Runs a quick Tesseract pass on a downscaled
+ copy to find word positions, computes the dominant left-edge column, fits a
+ line through those points and rotates the full-resolution image.
+
+ Args:
+ image_data: Raw image bytes (PNG/JPEG).
+ lang: Tesseract language string for the quick pass.
+ downscale_factor: Shrink factor for the quick Tesseract pass (0.5 = 50%).
+
+ Returns:
+ Tuple of (rotated image as PNG bytes, detected angle in degrees).
+ """
+ if not CV2_AVAILABLE or not TESSERACT_AVAILABLE:
+ return image_data, 0.0
+
+ # 1. Decode image
+ img_array = np.frombuffer(image_data, dtype=np.uint8)
+ img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
+ if img is None:
+ logger.warning("deskew_by_word_alignment: could not decode image")
+ return image_data, 0.0
+
+ orig_h, orig_w = img.shape[:2]
+
+ # 2. Downscale for fast Tesseract pass
+ small_w = int(orig_w * downscale_factor)
+ small_h = int(orig_h * downscale_factor)
+ small = cv2.resize(img, (small_w, small_h), interpolation=cv2.INTER_AREA)
+
+ # 3. Quick Tesseract — word-level positions
+ pil_small = Image.fromarray(cv2.cvtColor(small, cv2.COLOR_BGR2RGB))
+ try:
+ data = pytesseract.image_to_data(
+ pil_small, lang=lang, config="--psm 6 --oem 3",
+ output_type=pytesseract.Output.DICT,
+ )
+ except Exception as e:
+ logger.warning(f"deskew_by_word_alignment: Tesseract failed: {e}")
+ return image_data, 0.0
+
+ # 4. Per text-line, find the left-most word start
+ # Group by (block_num, par_num, line_num)
+ from collections import defaultdict
+ line_groups: Dict[tuple, list] = defaultdict(list)
+ for i in range(len(data["text"])):
+ text = (data["text"][i] or "").strip()
+ conf = int(data["conf"][i])
+ if not text or conf < 20:
+ continue
+ key = (data["block_num"][i], data["par_num"][i], data["line_num"][i])
+ line_groups[key].append(i)
+
+ if len(line_groups) < 5:
+ logger.info(f"deskew_by_word_alignment: only {len(line_groups)} lines, skipping")
+ return image_data, 0.0
+
+ # For each line, pick the word with smallest 'left' → compute (left_x, center_y)
+ # Scale back to original resolution
+ scale = 1.0 / downscale_factor
+ points = [] # list of (x, y) in original-image coords
+ for key, indices in line_groups.items():
+ best_idx = min(indices, key=lambda i: data["left"][i])
+ lx = data["left"][best_idx] * scale
+ top = data["top"][best_idx] * scale
+ h = data["height"][best_idx] * scale
+ cy = top + h / 2.0
+ points.append((lx, cy))
+
+ # 5. Find dominant left-edge column + compute angle
+ xs = np.array([p[0] for p in points])
+ ys = np.array([p[1] for p in points])
+ median_x = float(np.median(xs))
+ tolerance = orig_w * 0.03 # 3% of image width
+
+ mask = np.abs(xs - median_x) <= tolerance
+ filtered_xs = xs[mask]
+ filtered_ys = ys[mask]
+
+ if len(filtered_xs) < 5:
+ logger.info(f"deskew_by_word_alignment: only {len(filtered_xs)} aligned points after filter, skipping")
+ return image_data, 0.0
+
+ # polyfit: x = a*y + b → a = dx/dy → angle = arctan(a)
+ coeffs = np.polyfit(filtered_ys, filtered_xs, 1)
+ slope = coeffs[0] # dx/dy
+ angle_rad = np.arctan(slope)
+ angle_deg = float(np.degrees(angle_rad))
+
+ # Clamp to ±5°
+ angle_deg = max(-5.0, min(5.0, angle_deg))
+
+ logger.info(f"deskew_by_word_alignment: detected {angle_deg:.2f}° from {len(filtered_xs)} points "
+ f"(total lines: {len(line_groups)})")
+
+ if abs(angle_deg) < 0.05:
+ return image_data, 0.0
+
+ # 6. Rotate full-res image
+ center = (orig_w // 2, orig_h // 2)
+ M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
+ rotated = cv2.warpAffine(img, M, (orig_w, orig_h),
+ flags=cv2.INTER_LINEAR,
+ borderMode=cv2.BORDER_REPLICATE)
+
+ # Encode back to PNG
+ success, png_buf = cv2.imencode(".png", rotated)
+ if not success:
+ logger.warning("deskew_by_word_alignment: PNG encoding failed")
+ return image_data, 0.0
+
+ return png_buf.tobytes(), angle_deg
+
+
# =============================================================================
# Stage 3: Dewarp (Book Curvature) — Pass-Through for now
# =============================================================================
diff --git a/klausur-service/backend/vocab_worksheet_api.py b/klausur-service/backend/vocab_worksheet_api.py
index 495c6b9..29b4c69 100644
--- a/klausur-service/backend/vocab_worksheet_api.py
+++ b/klausur-service/backend/vocab_worksheet_api.py
@@ -2134,7 +2134,22 @@ async def extract_with_boxes(session_id: str, page_number: int):
# Convert page to hires image
image_data = await convert_pdf_page_to_image(pdf_data, page_number, thumbnail=False)
- # Extract entries with boxes
+ # Deskew image before OCR
+ deskew_angle = 0.0
+ try:
+ from cv_vocab_pipeline import deskew_image_by_word_alignment, CV2_AVAILABLE
+ if CV2_AVAILABLE:
+ image_data, deskew_angle = deskew_image_by_word_alignment(image_data)
+ logger.info(f"Deskew: {deskew_angle:.2f}° for page {page_number}")
+ except Exception as e:
+ logger.warning(f"Deskew failed for page {page_number}: {e}")
+
+ # Cache deskewed image in session for later serving
+ if "deskewed_images" not in session:
+ session["deskewed_images"] = {}
+ session["deskewed_images"][str(page_number)] = image_data
+
+ # Extract entries with boxes (now on deskewed image)
result = await extract_entries_with_boxes(image_data)
# Cache in session
@@ -2148,9 +2163,35 @@ async def extract_with_boxes(session_id: str, page_number: int):
"entry_count": len(result["entries"]),
"image_width": result["image_width"],
"image_height": result["image_height"],
+ "deskew_angle": round(deskew_angle, 2),
+ "deskewed": abs(deskew_angle) > 0.05,
}
+@router.get("/sessions/{session_id}/deskewed-image/{page_number}")
+async def get_deskewed_image(session_id: str, page_number: int):
+ """Return the deskewed page image as PNG.
+
+ Falls back to the original hires image if no deskewed version is cached.
+ """
+ if session_id not in _sessions:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ session = _sessions[session_id]
+ deskewed = session.get("deskewed_images", {}).get(str(page_number))
+
+ if deskewed:
+ return StreamingResponse(io.BytesIO(deskewed), media_type="image/png")
+
+ # Fallback: render original hires image
+ pdf_data = session.get("pdf_data")
+ if not pdf_data:
+ raise HTTPException(status_code=400, detail="No PDF uploaded for this session")
+
+ image_data = await convert_pdf_page_to_image(pdf_data, page_number, thumbnail=False)
+ return StreamingResponse(io.BytesIO(image_data), media_type="image/png")
+
+
@router.post("/sessions/{session_id}/ground-truth/{page_number}")
async def save_ground_truth(session_id: str, page_number: int, data: dict = Body(...)):
"""Save ground truth labels for a page.