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