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:
BreakPilot Dev
2026-02-10 12:12:38 +01:00
parent 8c77df494b
commit dd1771be1e
10 changed files with 1985 additions and 3 deletions

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}>