This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/components/catalog-manager/CatalogEntryForm.tsx
BreakPilot Dev 76b108a29f 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>
2026-02-10 12:12:38 +01:00

393 lines
13 KiB
TypeScript

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