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:
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 */}
|
||||
<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}>
|
||||
|
||||
Reference in New Issue
Block a user