feat(admin-v2): Katalogverwaltung ins Admin-Dashboard integrieren
Katalogverwaltung von /sdk/catalog-manager nach /dashboard/catalog-manager verschoben, damit sie im Admin-Dashboard mit Sidebar erscheint statt im SDK-Bereich. Shared Components extrahiert, SDK-Route bleibt funktionsfaehig. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
12
admin-v2/app/(admin)/dashboard/catalog-manager/page.tsx
Normal file
12
admin-v2/app/(admin)/dashboard/catalog-manager/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
admin-v2/app/(sdk)/sdk/catalog-manager/page.tsx
Normal file
7
admin-v2/app/(sdk)/sdk/catalog-manager/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent'
|
||||||
|
|
||||||
|
export default function CatalogManagerPage() {
|
||||||
|
return <CatalogManagerContent />
|
||||||
|
}
|
||||||
392
admin-v2/components/catalog-manager/CatalogEntryForm.tsx
Normal file
392
admin-v2/components/catalog-manager/CatalogEntryForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
381
admin-v2/components/catalog-manager/CatalogManagerContent.tsx
Normal file
381
admin-v2/components/catalog-manager/CatalogManagerContent.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
admin-v2/components/catalog-manager/CatalogModuleTabs.tsx
Normal file
121
admin-v2/components/catalog-manager/CatalogModuleTabs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
254
admin-v2/components/catalog-manager/CatalogTable.tsx
Normal file
254
admin-v2/components/catalog-manager/CatalogTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -184,8 +184,10 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
|
|||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<div className="px-2 space-y-1">
|
<div className="px-2 space-y-1">
|
||||||
{visibleCategories.map((category) => {
|
{visibleCategories.map((category) => {
|
||||||
const categoryHref = `/${category.id === 'compliance' ? 'compliance' : category.id}`
|
const categoryHref = category.id === 'compliance-sdk' ? '/dashboard/catalog-manager' : `/${category.id === 'compliance' ? 'compliance' : category.id}`
|
||||||
const isCategoryActive = pathname.startsWith(categoryHref)
|
const isCategoryActive = category.id === 'compliance-sdk'
|
||||||
|
? category.modules.some(m => pathname.startsWith(m.href))
|
||||||
|
: pathname.startsWith(categoryHref)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category.id}>
|
<div key={category.id}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* DSGVO (Datenschutz) and Compliance (Audit & GRC) are now separate
|
* 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 {
|
export interface NavModule {
|
||||||
id: string
|
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
|
// KI & Automatisierung
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
{
|
{
|
||||||
@@ -486,6 +507,15 @@ export const navigation: NavCategory[] = [
|
|||||||
audience: ['Lehrer', 'Entwickler'],
|
audience: ['Lehrer', 'Entwickler'],
|
||||||
oldAdminPath: '/admin/klausur-korrektur',
|
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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
665
admin-v2/lib/sdk/catalog-manager/catalog-registry.ts
Normal file
665
admin-v2/lib/sdk/catalog-manager/catalog-registry.ts
Normal 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)
|
||||||
|
}
|
||||||
118
admin-v2/lib/sdk/catalog-manager/types.ts
Normal file
118
admin-v2/lib/sdk/catalog-manager/types.ts
Normal 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',
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user