This commit is contained in:
Benjamin Admin
2026-02-10 12:54:08 +01:00
13 changed files with 2238 additions and 29 deletions

View File

@@ -0,0 +1,12 @@
'use client'
import { SDKProvider } from '@/lib/sdk/context'
import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent'
export default function AdminCatalogManagerPage() {
return (
<SDKProvider>
<CatalogManagerContent />
</SDKProvider>
)
}

View File

@@ -0,0 +1,7 @@
'use client'
import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent'
export default function CatalogManagerPage() {
return <CatalogManagerContent />
}

View File

@@ -0,0 +1,392 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { X } from 'lucide-react'
import type {
CatalogMeta,
CatalogEntry,
CatalogFieldSchema,
} from '@/lib/sdk/catalog-manager/types'
interface CatalogEntryFormProps {
catalog: CatalogMeta
entry?: CatalogEntry | null
onSave: (data: Record<string, unknown>) => void
onCancel: () => void
}
export default function CatalogEntryForm({
catalog,
entry,
onSave,
onCancel,
}: CatalogEntryFormProps) {
const isEditMode = entry !== null && entry !== undefined
const isSystemEntry = isEditMode && entry?.source === 'system'
const isCreateMode = !isEditMode
const title = isSystemEntry
? 'Details'
: isEditMode
? 'Eintrag bearbeiten'
: 'Neuer Eintrag'
const [formData, setFormData] = useState<Record<string, unknown>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
// Initialize form data
useEffect(() => {
if (isEditMode && entry) {
const initialData: Record<string, unknown> = {}
for (const field of catalog.fields) {
initialData[field.key] = entry.data?.[field.key] ?? getDefaultValue(field)
}
setFormData(initialData)
} else {
const initialData: Record<string, unknown> = {}
for (const field of catalog.fields) {
initialData[field.key] = getDefaultValue(field)
}
setFormData(initialData)
}
}, [entry, catalog.fields, isEditMode])
function getDefaultValue(field: CatalogFieldSchema): unknown {
switch (field.type) {
case 'text':
case 'textarea':
return ''
case 'number':
return field.min ?? 0
case 'select':
return ''
case 'multiselect':
return []
case 'boolean':
return false
case 'tags':
return ''
default:
return ''
}
}
const handleFieldChange = useCallback(
(key: string, value: unknown) => {
setFormData(prev => ({ ...prev, [key]: value }))
// Clear error on change
if (errors[key]) {
setErrors(prev => {
const next = { ...prev }
delete next[key]
return next
})
}
},
[errors]
)
const handleMultiselectToggle = useCallback(
(key: string, option: string) => {
setFormData(prev => {
const current = (prev[key] as string[]) || []
const updated = current.includes(option)
? current.filter(v => v !== option)
: [...current, option]
return { ...prev, [key]: updated }
})
},
[]
)
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
for (const field of catalog.fields) {
if (field.required) {
const value = formData[field.key]
if (value === undefined || value === null || value === '') {
newErrors[field.key] = `${field.label} ist ein Pflichtfeld`
} else if (Array.isArray(value) && value.length === 0) {
newErrors[field.key] = `Mindestens ein Wert erforderlich`
}
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (isSystemEntry) return
if (validate()) {
// Convert tags string to array before saving
const processedData = { ...formData }
for (const field of catalog.fields) {
if (field.type === 'tags' && typeof processedData[field.key] === 'string') {
processedData[field.key] = (processedData[field.key] as string)
.split(',')
.map(t => t.trim())
.filter(Boolean)
}
}
onSave(processedData)
}
}
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
const renderField = (field: CatalogFieldSchema) => {
const value = formData[field.key]
const hasError = !!errors[field.key]
const isDisabled = isSystemEntry
const baseInputClasses = `w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-colors ${
hasError
? 'border-red-400 dark:border-red-500'
: 'border-gray-300 dark:border-gray-600'
} ${isDisabled ? 'opacity-60 cursor-not-allowed bg-gray-50 dark:bg-gray-800' : ''}`
switch (field.type) {
case 'text':
return (
<input
type="text"
value={(value as string) || ''}
onChange={e => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder || ''}
disabled={isDisabled}
className={baseInputClasses}
/>
)
case 'textarea':
return (
<textarea
value={(value as string) || ''}
onChange={e => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder || ''}
disabled={isDisabled}
rows={3}
className={`${baseInputClasses} resize-y`}
/>
)
case 'number':
return (
<input
type="number"
value={(value as number) ?? ''}
onChange={e =>
handleFieldChange(
field.key,
e.target.value === '' ? '' : Number(e.target.value)
)
}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={isDisabled}
className={baseInputClasses}
/>
)
case 'select':
return (
<select
value={(value as string) || ''}
onChange={e => handleFieldChange(field.key, e.target.value)}
disabled={isDisabled}
className={baseInputClasses}
>
<option value="">-- Auswaehlen --</option>
{(field.options || []).map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
case 'multiselect':
return (
<div className="space-y-1.5">
{(field.options || []).map(opt => {
const checked = ((value as string[]) || []).includes(opt.value)
return (
<label
key={opt.value}
className={`flex items-center gap-2 text-sm cursor-pointer ${
isDisabled ? 'opacity-60 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => handleMultiselectToggle(field.key, opt.value)}
disabled={isDisabled}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
/>
<span className="text-gray-700 dark:text-gray-300">
{opt.label}
</span>
</label>
)
})}
</div>
)
case 'boolean':
return (
<label
className={`flex items-center gap-3 cursor-pointer ${
isDisabled ? 'opacity-60 cursor-not-allowed' : ''
}`}
>
<button
type="button"
role="switch"
aria-checked={!!value}
disabled={isDisabled}
onClick={() => !isDisabled && handleFieldChange(field.key, !value)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 ${
value
? 'bg-violet-600'
: 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition-transform ${
value ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-700 dark:text-gray-300">
{value ? 'Ja' : 'Nein'}
</span>
</label>
)
case 'tags':
return (
<div>
<input
type="text"
value={
Array.isArray(value)
? (value as string[]).join(', ')
: (value as string) || ''
}
onChange={e => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder || 'Komma-getrennte Tags eingeben...'}
disabled={isDisabled}
className={baseInputClasses}
/>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
Mehrere Tags durch Komma trennen
</p>
</div>
)
default:
return null
}
}
return (
// Backdrop
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={e => {
if (e.target === e.currentTarget) onCancel()
}}
>
{/* Modal */}
<div className="relative w-full max-w-lg max-h-[90vh] bg-white dark:bg-gray-800 rounded-xl shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{catalog.name}
</p>
</div>
<button
onClick={onCancel}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Schliessen"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-5">
{catalog.fields.map(field => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{field.label}
{field.required && !isSystemEntry && (
<span className="text-red-500 ml-0.5">*</span>
)}
</label>
{field.description && (
<p className="text-xs text-gray-400 dark:text-gray-500 mb-1.5">
{field.description}
</p>
)}
{renderField(field)}
{errors[field.key] && (
<p className="mt-1 text-xs text-red-500 dark:text-red-400">
{errors[field.key]}
</p>
)}
</div>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
{isSystemEntry ? (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Schliessen
</button>
) : (
<>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors"
>
{isCreateMode ? 'Erstellen' : 'Speichern'}
</button>
</>
)}
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,381 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import {
Database,
Search,
Plus,
ChevronDown,
ChevronRight,
BarChart3,
Layers,
Users,
Settings,
} from 'lucide-react'
import type {
CatalogId,
CatalogModule,
CatalogEntry,
CatalogMeta,
CustomCatalogEntry,
} from '@/lib/sdk/catalog-manager/types'
import { CATALOG_MODULE_LABELS } from '@/lib/sdk/catalog-manager/types'
import {
CATALOG_REGISTRY,
getAllEntries,
getCatalogsByModule,
getOverviewStats,
searchCatalog,
} from '@/lib/sdk/catalog-manager/catalog-registry'
import CatalogModuleTabs from '@/components/catalog-manager/CatalogModuleTabs'
import CatalogTable from '@/components/catalog-manager/CatalogTable'
import CatalogEntryForm from '@/components/catalog-manager/CatalogEntryForm'
// =============================================================================
// STAT CARD COMPONENT
// =============================================================================
interface StatCardProps {
icon: React.ReactNode
label: string
value: number
color: 'violet' | 'blue' | 'emerald' | 'amber'
}
const colorMap = {
violet: {
bg: 'bg-violet-50 dark:bg-violet-900/20',
text: 'text-violet-600 dark:text-violet-400',
},
blue: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
text: 'text-blue-600 dark:text-blue-400',
},
emerald: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
text: 'text-emerald-600 dark:text-emerald-400',
},
amber: {
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-600 dark:text-amber-400',
},
}
function StatCard({ icon, label, value, color }: StatCardProps) {
const colors = colorMap[color]
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl ${colors.bg}`}>
<div className={colors.text}>{icon}</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{label}</p>
<p className={`text-lg font-bold ${colors.text}`}>{value.toLocaleString('de-DE')}</p>
</div>
</div>
)
}
// =============================================================================
// MAIN CONTENT COMPONENT
// =============================================================================
export function CatalogManagerContent() {
const { state, dispatch } = useSDK()
const customCatalogs = state.customCatalogs ?? {}
// UI State
const [activeModule, setActiveModule] = useState<CatalogModule | 'all'>('all')
const [selectedCatalogId, setSelectedCatalogId] = useState<CatalogId | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [expandedCatalogs, setExpandedCatalogs] = useState<Set<CatalogId>>(new Set())
const [formState, setFormState] = useState<{
open: boolean
catalog: CatalogMeta | null
entry: CatalogEntry | null
}>({ open: false, catalog: null, entry: null })
// Computed data
const overviewStats = useMemo(() => getOverviewStats(customCatalogs), [customCatalogs])
const visibleCatalogs = useMemo(() => {
if (activeModule === 'all') {
return Object.values(CATALOG_REGISTRY)
}
return getCatalogsByModule(activeModule)
}, [activeModule])
const selectedCatalog = selectedCatalogId ? CATALOG_REGISTRY[selectedCatalogId] : null
const catalogEntries = useMemo(() => {
if (!selectedCatalogId) return []
const custom = customCatalogs[selectedCatalogId] || []
if (searchQuery.trim()) {
return searchCatalog(selectedCatalogId, searchQuery, custom)
}
return getAllEntries(selectedCatalogId, custom)
}, [selectedCatalogId, customCatalogs, searchQuery])
// Handlers
const toggleCatalogExpand = useCallback((id: CatalogId) => {
setExpandedCatalogs(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const handleSelectCatalog = useCallback((id: CatalogId) => {
setSelectedCatalogId(id)
setSearchQuery('')
}, [])
const handleAddEntry = useCallback((catalog: CatalogMeta) => {
setFormState({ open: true, catalog, entry: null })
}, [])
const handleEditEntry = useCallback((catalog: CatalogMeta, entry: CatalogEntry) => {
setFormState({ open: true, catalog, entry })
}, [])
const handleCloseForm = useCallback(() => {
setFormState({ open: false, catalog: null, entry: null })
}, [])
const handleSaveEntry = useCallback((data: Record<string, unknown>) => {
if (!formState.catalog) return
if (formState.entry && formState.entry.source === 'custom') {
// Update existing custom entry
dispatch({
type: 'UPDATE_CUSTOM_CATALOG_ENTRY',
payload: {
catalogId: formState.catalog.id,
entryId: formState.entry.id,
data,
},
})
} else {
// Create new custom entry
const newEntry: CustomCatalogEntry = {
id: crypto.randomUUID(),
catalogId: formState.catalog.id,
data,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
dispatch({
type: 'ADD_CUSTOM_CATALOG_ENTRY',
payload: newEntry,
})
}
handleCloseForm()
}, [formState, dispatch, handleCloseForm])
const handleDeleteEntry = useCallback((catalogId: CatalogId, entryId: string) => {
dispatch({
type: 'DELETE_CUSTOM_CATALOG_ENTRY',
payload: { catalogId, entryId },
})
}, [dispatch])
// =============================================================================
// RENDER
// =============================================================================
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-violet-100 dark:bg-violet-900/30 rounded-xl">
<Database className="h-6 w-6 text-violet-600 dark:text-violet-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Katalogverwaltung
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Alle SDK-Kataloge und Auswahltabellen zentral verwalten
</p>
</div>
</div>
</div>
{/* Stats Bar */}
<div className="grid grid-cols-4 gap-4 mt-6">
<StatCard
icon={<Layers className="h-5 w-5" />}
label="Kataloge"
value={overviewStats.totalCatalogs}
color="violet"
/>
<StatCard
icon={<Database className="h-5 w-5" />}
label="System-Eintraege"
value={overviewStats.totalSystemEntries}
color="blue"
/>
<StatCard
icon={<Users className="h-5 w-5" />}
label="Eigene Eintraege"
value={overviewStats.totalCustomEntries}
color="emerald"
/>
<StatCard
icon={<BarChart3 className="h-5 w-5" />}
label="Gesamt"
value={overviewStats.totalEntries}
color="amber"
/>
</div>
</div>
</div>
{/* Module Tabs */}
<div className="max-w-7xl mx-auto px-6 mt-6">
<CatalogModuleTabs
activeModule={activeModule}
onModuleChange={setActiveModule}
stats={overviewStats}
/>
</div>
{/* Content Area */}
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="grid grid-cols-12 gap-6">
{/* Left: Catalog List */}
<div className="col-span-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{activeModule === 'all'
? 'Alle Kataloge'
: CATALOG_MODULE_LABELS[activeModule]}
<span className="ml-2 text-gray-400 font-normal">
({visibleCatalogs.length})
</span>
</h2>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700/50 max-h-[calc(100vh-400px)] overflow-y-auto">
{visibleCatalogs.map(catalog => {
const customCount = customCatalogs[catalog.id]?.length ?? 0
const isSelected = selectedCatalogId === catalog.id
return (
<button
key={catalog.id}
onClick={() => handleSelectCatalog(catalog.id)}
className={`w-full text-left px-4 py-3 transition-colors ${
isSelected
? 'bg-violet-50 dark:bg-violet-900/20 border-l-3 border-l-violet-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className={`text-sm font-medium truncate ${
isSelected
? 'text-violet-700 dark:text-violet-300'
: 'text-gray-900 dark:text-white'
}`}>
{catalog.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{catalog.description}
</p>
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{catalog.systemCount}
</span>
{customCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
+{customCount}
</span>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Right: Catalog Detail / Table */}
<div className="col-span-8">
{selectedCatalog ? (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Catalog Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedCatalog.name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedCatalog.description}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{CATALOG_MODULE_LABELS[selectedCatalog.module]}
</span>
{selectedCatalog.allowCustom && (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400">
Erweiterbar
</span>
)}
</div>
</div>
</div>
{/* Table */}
<CatalogTable
catalog={selectedCatalog}
entries={catalogEntries}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onEditCustomEntry={(entry) => handleEditEntry(selectedCatalog, entry)}
onDeleteCustomEntry={(entryId) => handleDeleteEntry(selectedCatalog.id, entryId)}
onAddEntry={() => handleAddEntry(selectedCatalog)}
/>
</div>
) : (
/* Empty State */
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 p-12">
<div className="text-center">
<Settings className="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Katalog auswaehlen
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
Waehlen Sie einen Katalog aus der Liste links, um dessen Eintraege anzuzeigen und zu verwalten.
</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Form Modal */}
{formState.open && formState.catalog && (
<CatalogEntryForm
catalog={formState.catalog}
entry={formState.entry}
onSave={handleSaveEntry}
onCancel={handleCloseForm}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import { useRef, useEffect } from 'react'
import {
ShieldAlert,
FileText,
Building2,
Bot,
BookOpen,
Database,
} from 'lucide-react'
import type {
CatalogModule,
CatalogOverviewStats,
} from '@/lib/sdk/catalog-manager/types'
import { CATALOG_MODULE_LABELS } from '@/lib/sdk/catalog-manager/types'
interface CatalogModuleTabsProps {
activeModule: CatalogModule | 'all'
onModuleChange: (module: CatalogModule | 'all') => void
stats: CatalogOverviewStats
}
const MODULE_ICON_MAP: Record<CatalogModule, React.ComponentType<{ className?: string }>> = {
dsfa: ShieldAlert,
vvt: FileText,
vendor: Building2,
ai_act: Bot,
reference: BookOpen,
}
interface TabDefinition {
key: CatalogModule | 'all'
label: string
icon: React.ComponentType<{ className?: string }>
}
export default function CatalogModuleTabs({
activeModule,
onModuleChange,
stats,
}: CatalogModuleTabsProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeTabRef = useRef<HTMLButtonElement>(null)
// Scroll active tab into view on mount and when active changes
useEffect(() => {
if (activeTabRef.current && scrollRef.current) {
const container = scrollRef.current
const tab = activeTabRef.current
const containerRect = container.getBoundingClientRect()
const tabRect = tab.getBoundingClientRect()
if (tabRect.left < containerRect.left || tabRect.right > containerRect.right) {
tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
}
}, [activeModule])
const tabs: TabDefinition[] = [
{ key: 'all', label: 'Alle', icon: Database },
...Object.entries(CATALOG_MODULE_LABELS).map(([key, label]) => ({
key: key as CatalogModule,
label,
icon: MODULE_ICON_MAP[key as CatalogModule],
})),
]
const getCount = (key: CatalogModule | 'all'): number => {
if (key === 'all') {
return stats.totalEntries
}
return stats.byModule?.[key]?.entries ?? 0
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div
ref={scrollRef}
className="flex overflow-x-auto scrollbar-hide border-b border-gray-200 dark:border-gray-700"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{tabs.map(tab => {
const isActive = activeModule === tab.key
const Icon = tab.icon
const count = getCount(tab.key)
return (
<button
key={tab.key}
ref={isActive ? activeTabRef : undefined}
onClick={() => onModuleChange(tab.key)}
className={`relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors shrink-0 ${
isActive
? 'text-violet-700 dark:text-violet-400 font-semibold'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span>{tab.label}</span>
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-medium rounded-full ${
isActive
? 'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{count}
</span>
{/* Active indicator line */}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-violet-600 dark:bg-violet-400 rounded-t" />
)}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,254 @@
'use client'
import { useState, useMemo } from 'react'
import {
Search,
Plus,
Pencil,
Trash2,
Eye,
ChevronUp,
ChevronDown,
Database,
} from 'lucide-react'
import type { CatalogMeta, CatalogEntry } from '@/lib/sdk/catalog-manager/types'
interface CatalogTableProps {
catalog: CatalogMeta
entries: CatalogEntry[]
searchQuery: string
onSearchChange: (query: string) => void
onEditCustomEntry: (entry: CatalogEntry) => void
onDeleteCustomEntry: (entryId: string) => void
onAddEntry: () => void
}
type SortField = 'id' | 'name' | 'category' | 'type'
type SortDirection = 'asc' | 'desc'
export default function CatalogTable({
catalog,
entries,
searchQuery,
onSearchChange,
onEditCustomEntry,
onDeleteCustomEntry,
onAddEntry,
}: CatalogTableProps) {
const [sortField, setSortField] = useState<SortField>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortField(field)
setSortDirection('asc')
}
}
const sortedEntries = useMemo(() => {
const sorted = [...entries].sort((a, b) => {
let aVal = ''
let bVal = ''
switch (sortField) {
case 'id':
aVal = a.id
bVal = b.id
break
case 'name':
aVal = a.displayName
bVal = b.displayName
break
case 'category':
aVal = a.category || ''
bVal = b.category || ''
break
case 'type':
aVal = a.source
bVal = b.source
break
}
const cmp = aVal.localeCompare(bVal, 'de')
return sortDirection === 'asc' ? cmp : -cmp
})
return sorted
}, [entries, sortField, sortDirection])
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return (
<span className="ml-1 inline-flex flex-col opacity-30">
<ChevronUp className="h-3 w-3 -mb-1" />
<ChevronDown className="h-3 w-3" />
</span>
)
}
return sortDirection === 'asc' ? (
<ChevronUp className="ml-1 h-3.5 w-3.5 inline" />
) : (
<ChevronDown className="ml-1 h-3.5 w-3.5 inline" />
)
}
return (
<div>
{/* Search & Add Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
placeholder="Eintraege durchsuchen..."
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent"
/>
</div>
{catalog.allowCustom && (
<button
onClick={onAddEntry}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors shrink-0"
>
<Plus className="h-4 w-4" />
Eintrag hinzufuegen
</button>
)}
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
onClick={() => handleSort('name')}
>
Name
<SortIcon field="name" />
</th>
{catalog.categoryField && (
<th
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
onClick={() => handleSort('category')}
>
Kategorie
<SortIcon field="category" />
</th>
)}
<th
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
onClick={() => handleSort('type')}
>
Quelle
<SortIcon field="type" />
</th>
<th className="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
Aktionen
</th>
</tr>
</thead>
<tbody>
{sortedEntries.length === 0 ? (
<tr>
<td
colSpan={catalog.categoryField ? 4 : 3}
className="px-4 py-12 text-center text-gray-500 dark:text-gray-400"
>
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 opacity-40" />
<p className="text-sm">
{searchQuery
? `Keine Eintraege gefunden fuer "${searchQuery}"`
: 'Keine Eintraege vorhanden'}
</p>
</div>
</td>
</tr>
) : (
sortedEntries.map((entry) => (
<tr
key={`${entry.source}-${entry.id}`}
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
>
<td className="px-4 py-2.5">
<div>
<p className="text-gray-900 dark:text-gray-100 font-medium truncate max-w-xs">
{entry.displayName}
</p>
{entry.displayDescription && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs mt-0.5">
{entry.displayDescription}
</p>
)}
</div>
</td>
{catalog.categoryField && (
<td className="px-4 py-2.5 text-gray-600 dark:text-gray-300">
{entry.category || '\u2014'}
</td>
)}
<td className="px-4 py-2.5">
{entry.source === 'system' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
System
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
Benutzerdefiniert
</span>
)}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
{entry.source === 'system' ? (
<button
onClick={() => onEditCustomEntry(entry)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
title="Details anzeigen"
>
<Eye className="h-3.5 w-3.5" />
Details
</button>
) : (
<>
<button
onClick={() => onEditCustomEntry(entry)}
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onDeleteCustomEntry(entry.id)}
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer with count */}
{sortedEntries.length > 0 && (
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
{sortedEntries.length} {sortedEntries.length === 1 ? 'Eintrag' : 'Eintraege'}
{searchQuery && ` (gefiltert)`}
</div>
)}
</div>
)
}

View File

@@ -184,8 +184,10 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
{/* Categories */}
<div className="px-2 space-y-1">
{visibleCategories.map((category) => {
const categoryHref = `/${category.id === 'compliance' ? 'compliance' : category.id}`
const isCategoryActive = pathname.startsWith(categoryHref)
const categoryHref = category.id === 'compliance-sdk' ? '/dashboard/catalog-manager' : `/${category.id === 'compliance' ? 'compliance' : category.id}`
const isCategoryActive = category.id === 'compliance-sdk'
? category.modules.some(m => pathname.startsWith(m.href))
: pathname.startsWith(categoryHref)
return (
<div key={category.id}>

View File

@@ -111,6 +111,9 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 })
const [showSummary, setShowSummary] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
const [imageUrl, setImageUrl] = useState(pageImageUrl)
const [deskewAngle, setDeskewAngle] = useState<number | null>(null)
// Editable fields for current entry
const [editEn, setEditEn] = useState('')
@@ -120,13 +123,19 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
const panelRef = useRef<HTMLDivElement>(null)
const enInputRef = useRef<HTMLInputElement>(null)
// Reset image URL when page changes
useEffect(() => {
setImageUrl(pageImageUrl)
setDeskewAngle(null)
}, [pageImageUrl])
// Load natural image dimensions
useEffect(() => {
if (!pageImageUrl) return
if (!imageUrl) return
const img = new Image()
img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight })
img.src = pageImageUrl
}, [pageImageUrl])
img.src = imageUrl
}, [imageUrl])
// Sync edit fields when current entry changes
useEffect(() => {
@@ -157,6 +166,12 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const }))
setEntries(loaded)
setCurrentIndex(0)
// Switch to deskewed image if available
if (data.deskewed) {
setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`)
setDeskewAngle(data.deskew_angle)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraction failed')
} finally {
@@ -225,9 +240,15 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
// ---------- Keyboard shortcuts ----------
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
e.preventDefault()
setIsFullscreen(false)
return
}
if (entries.length === 0 || showSummary) return
const handler = (e: KeyboardEvent) => {
// Don't capture when typing in inputs
const tag = (e.target as HTMLElement)?.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA'
@@ -251,7 +272,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [entries.length, showSummary, confirmEntry, skipEntry, goTo, currentIndex])
}, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex])
// ---------- Computed ----------
@@ -298,8 +319,27 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
if (showSummary) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6" ref={panelRef}>
<h3 className="text-lg font-semibold text-slate-900 mb-4">Zusammenfassung</h3>
<div className={`bg-white rounded-xl border border-slate-200 p-6 ${
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none' : ''
}`} ref={panelRef}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Zusammenfassung</h3>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)}
</button>
</div>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">{confirmedCount}</div>
@@ -385,22 +425,47 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
// ---------- Render: Main Review UI ----------
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden" ref={panelRef}>
{/* Progress bar */}
<div className="h-1.5 bg-slate-100">
<div className={`bg-white rounded-xl border border-slate-200 overflow-hidden ${
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-white' : ''
}`} ref={panelRef}>
{/* Header with progress + fullscreen toggle */}
<div className="flex items-center gap-2 px-4 pt-2">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full">
<div
className="h-full bg-teal-500 transition-all duration-300"
className="h-full bg-teal-500 transition-all duration-300 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-slate-400 whitespace-nowrap">{currentIndex + 1}/{entries.length}</span>
{deskewAngle !== null && (
<span className="text-xs text-teal-600 whitespace-nowrap" title="Bild wurde begradigt">
{deskewAngle.toFixed(1)}&deg;
</span>
)}
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)}
</button>
</div>
<div className="flex flex-col lg:flex-row">
<div className={`flex flex-col ${isFullscreen ? 'lg:flex-row h-[calc(100vh-3rem)]' : 'lg:flex-row'}`}>
{/* Left: Page image with SVG overlay (2/3) */}
<div className="lg:w-2/3 p-4">
<div className={`${isFullscreen ? 'lg:w-2/3 p-4 overflow-y-auto h-full' : 'lg:w-2/3 p-4'}`}>
<div className="relative bg-slate-50 rounded-lg overflow-hidden">
{pageImageUrl && (
{imageUrl && (
<img
src={pageImageUrl}
src={imageUrl}
alt={`Seite ${selectedPage + 1}`}
className="w-full"
draggable={false}
@@ -451,13 +516,13 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
</div>
{/* Right: Crops + Edit fields (1/3) */}
<div className="lg:w-1/3 border-l border-slate-200 p-4 space-y-4">
<div className={`lg:w-1/3 border-l border-slate-200 p-4 space-y-4 ${isFullscreen ? 'overflow-y-auto h-full' : ''}`}>
{currentEntry && (
<>
{/* Row crop */}
{imageNatural.w > 0 && (
<ImageCrop
imageUrl={pageImageUrl}
imageUrl={imageUrl}
bbox={currentEntry.bbox}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
@@ -470,7 +535,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
<div className="grid grid-cols-3 gap-2">
{currentEntry.bbox_en.w > 0 && (
<ImageCrop
imageUrl={pageImageUrl}
imageUrl={imageUrl}
bbox={currentEntry.bbox_en}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
@@ -480,7 +545,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
)}
{currentEntry.bbox_de.w > 0 && (
<ImageCrop
imageUrl={pageImageUrl}
imageUrl={imageUrl}
bbox={currentEntry.bbox_de}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
@@ -490,7 +555,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
)}
{currentEntry.bbox_ex.w > 0 && (
<ImageCrop
imageUrl={pageImageUrl}
imageUrl={imageUrl}
bbox={currentEntry.bbox_ex}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
@@ -590,7 +655,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
{/* Keyboard hints */}
<div className="text-xs text-slate-400 text-center border-t border-slate-100 pt-2">
Enter = Bestaetigen &middot; Tab = Ueberspringen &middot; &larr;&rarr; = Navigieren
Enter = Bestaetigen &middot; Tab = Ueberspringen &middot; &larr;&rarr; = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
</div>
</>
)}

View File

@@ -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',
},
],
},
// =========================================================================

View File

@@ -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<string, unknown>)) {
return String((value as Record<string, string>).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<CatalogId, CatalogMeta> = {
'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<CatalogId, Record<string, unknown>[]> = {
'dsfa-risks': RISK_CATALOG as unknown as Record<string, unknown>[],
'dsfa-mitigations': MITIGATION_LIBRARY as unknown as Record<string, unknown>[],
'ai-risks': AI_RISK_CATALOG as unknown as Record<string, unknown>[],
'ai-mitigations': AI_MITIGATION_LIBRARY as unknown as Record<string, unknown>[],
'prohibited-ai-practices': PROHIBITED_AI_PRACTICES as unknown as Record<string, unknown>[],
'vvt-templates': VVT_BASELINE_CATALOG as unknown as Record<string, unknown>[],
'loeschfristen-templates': BASELINE_TEMPLATES as unknown as Record<string, unknown>[],
'vendor-templates': VENDOR_TEMPLATES as unknown as Record<string, unknown>[],
'country-risk-profiles': COUNTRY_RISK_PROFILES as unknown as Record<string, unknown>[],
'legal-bases': LEGAL_BASIS_INFO as unknown as Record<string, unknown>[],
'retention-periods': STANDARD_RETENTION_PERIODS as unknown as Record<string, unknown>[],
'eu-legal-frameworks': EU_BASE_FRAMEWORKS as unknown as Record<string, unknown>[],
'national-legal-frameworks': NATIONAL_FRAMEWORKS as unknown as Record<string, unknown>[],
'gdpr-enforcement-cases': GDPR_ENFORCEMENT_CASES as unknown as Record<string, unknown>[],
'wp248-criteria': WP248_CRITERIA as unknown as Record<string, unknown>[],
'sdm-goals': SDM_GOALS_ENTRIES as unknown as Record<string, unknown>[],
'dsfa-authority-resources': DSFA_AUTHORITY_RESOURCES as unknown as Record<string, unknown>[],
}
// =============================================================================
// ENTRY CONVERTERS
// =============================================================================
function systemEntryToCatalogEntry(
catalogId: CatalogId,
data: Record<string, unknown>,
): 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<CatalogModule, { catalogs: number; entries: number }>
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)
}

View File

@@ -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<string, unknown>
createdAt: string // ISO date
updatedAt: string // ISO date
createdBy?: string
}
// All custom entries, keyed by CatalogId
export type CustomCatalogs = Partial<Record<CatalogId, CustomCatalogEntry[]>>
// Combined view entry (system or custom)
export interface CatalogEntry {
id: string
catalogId: CatalogId
source: 'system' | 'custom'
data: Record<string, unknown>
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<CatalogModule, { catalogs: number; entries: number }>
}
// Module labels
export const CATALOG_MODULE_LABELS: Record<CatalogModule, string> = {
dsfa: 'DSFA & Risiken',
vvt: 'VVT & Loeschfristen',
vendor: 'Auftragsverarbeitung',
ai_act: 'AI Act',
reference: 'Referenzdaten',
}
// Module icons (lucide names)
export const CATALOG_MODULE_ICONS: Record<CatalogModule, string> = {
dsfa: 'ShieldAlert',
vvt: 'FileText',
vendor: 'Building2',
ai_act: 'Bot',
reference: 'BookOpen',
}

View File

@@ -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
# =============================================================================

View File

@@ -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.