feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
@@ -0,0 +1,479 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Save, Plus, Trash2, ChevronUp, ChevronDown,
GripVertical, Eye, EyeOff, ToggleLeft, ToggleRight,
} from 'lucide-react'
import api from '../../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Visibility = 'required' | 'optional' | 'hidden'
interface StdField {
key: string
label: string
visibility: Visibility
}
interface CompPair {
component_type: string
required: boolean
}
interface Template {
id: string
name: string
category_key: string
description?: string | null
is_active: boolean
standard_fields: any
component_schema: any
}
// ---------------------------------------------------------------------------
// All canonical standard field definitions (maps to DB columns in order_items)
// ---------------------------------------------------------------------------
const ALL_FIELD_DEFS: { key: string; defaultLabel: string }[] = [
{ key: 'ebene1', defaultLabel: 'Ebene 1' },
{ key: 'ebene2', defaultLabel: 'Ebene 2' },
{ key: 'baureihe', defaultLabel: 'Baureihe' },
{ key: 'pim_id', defaultLabel: 'PIM-ID' },
{ key: 'produkt_baureihe', defaultLabel: 'Produkt / Baureihe' },
{ key: 'gewaehltes_produkt', defaultLabel: 'Gewähltes Produkt' },
{ key: 'name_cad_modell', defaultLabel: 'Name CAD-Modell' },
{ key: 'gewuenschte_bildnummer',defaultLabel: 'Gewünschte Bildnummer' },
{ key: 'lagertyp', defaultLabel: 'Lagertyp' },
{ key: 'medias_rendering', defaultLabel: 'Medias Rendering' },
]
// ---------------------------------------------------------------------------
// Normalisation helpers
// ---------------------------------------------------------------------------
function normalizeFields(raw: any): StdField[] {
// New array format: [{key, label, visibility}]
if (Array.isArray(raw) && raw.length > 0 && raw[0].key) {
const existing = new Map(raw.map((f: StdField) => [f.key, f]))
// Preserve saved order, then append any missing canonical fields
const ordered: StdField[] = raw.filter((f: StdField) =>
ALL_FIELD_DEFS.some((d) => d.key === f.key),
)
ALL_FIELD_DEFS.forEach(({ key, defaultLabel }) => {
if (!existing.has(key)) {
ordered.push({ key, label: defaultLabel, visibility: 'optional' })
}
})
return ordered
}
// Legacy dict format {"0": {label, required}} or empty — use canonical defaults
return ALL_FIELD_DEFS.map(({ key, defaultLabel }) => ({
key,
label: defaultLabel,
visibility: 'optional' as Visibility,
}))
}
function normalizePairs(raw: any): CompPair[] {
if (!raw) return []
if (Array.isArray(raw.pairs)) return raw.pairs.map((p: any) => ({
component_type: p.component_type ?? p.part_name ?? '',
required: p.required ?? false,
}))
if (Array.isArray(raw)) return raw.map((p: any) => ({
component_type: p.component_type ?? p.part_name ?? '',
required: p.required ?? false,
}))
return []
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function moveItem<T>(arr: T[], from: number, to: number): T[] {
const next = [...arr]
const [item] = next.splice(from, 1)
next.splice(to, 0, item)
return next
}
const VIS_STYLES: Record<Visibility, string> = {
required: 'bg-accent text-white',
optional: 'bg-blue-500 text-white',
hidden: 'bg-surface-muted text-content-secondary',
}
function VisibilityToggle({
value,
onChange,
}: {
value: Visibility
onChange: (v: Visibility) => void
}) {
const cycle: Visibility[] = ['required', 'optional', 'hidden']
const labels: Record<Visibility, string> = { required: 'Required', optional: 'Optional', hidden: 'Hidden' }
return (
<button
type="button"
onClick={() => onChange(cycle[(cycle.indexOf(value) + 1) % 3])}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${VIS_STYLES[value]}`}
title="Click to cycle: Required → Optional → Hidden"
>
{labels[value]}
</button>
)
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function TemplateEditor({
template,
onClose,
}: {
template: Template
onClose: () => void
}) {
const qc = useQueryClient()
const [name, setName] = useState(template.name)
const [description, setDescription] = useState(template.description ?? '')
const [isActive, setIsActive] = useState(template.is_active)
const [fields, setFields] = useState<StdField[]>(() => normalizeFields(template.standard_fields))
const [pairs, setPairs] = useState<CompPair[]>(() => normalizePairs(template.component_schema))
const [showHidden, setShowHidden] = useState(false)
const [newFieldKey, setNewFieldKey] = useState('')
useEffect(() => {
setName(template.name)
setDescription(template.description ?? '')
setIsActive(template.is_active)
setFields(normalizeFields(template.standard_fields))
setPairs(normalizePairs(template.component_schema))
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
const saveMut = useMutation({
mutationFn: () =>
api.patch(`/templates/${template.id}`, {
name,
description: description || null,
is_active: isActive,
standard_fields: fields,
component_schema: { pairs },
}),
onSuccess: () => {
toast.success('Template saved')
qc.invalidateQueries({ queryKey: ['admin-templates'] })
qc.invalidateQueries({ queryKey: ['templates'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to save'),
})
// ---- Standard fields helpers ----
function updateField(i: number, patch: Partial<StdField>) {
setFields((f) => f.map((x, j) => (j === i ? { ...x, ...patch } : x)))
}
function removeField(i: number) {
// Don't delete — mark hidden so it stays in DB but hidden in UI
updateField(i, { visibility: 'hidden' })
}
const hiddenKeys = new Set(fields.filter((f) => f.visibility === 'hidden').map((f) => f.key))
const availableToAdd = ALL_FIELD_DEFS.filter((d) => hiddenKeys.has(d.key))
function restoreField(key: string) {
setFields((f) =>
f.map((x) => (x.key === key ? { ...x, visibility: 'optional' } : x)),
)
setNewFieldKey('')
}
// ---- Component pair helpers ----
function updatePair(i: number, patch: Partial<CompPair>) {
setPairs((p) => p.map((x, j) => (j === i ? { ...x, ...patch } : x)))
}
function addPair() {
setPairs((p) => [...p, { component_type: '', required: false }])
}
function removePair(i: number) {
setPairs((p) => p.filter((_, j) => j !== i))
}
// ---- Visible fields for rendering ----
const visibleFields = showHidden ? fields : fields.filter((f) => f.visibility !== 'hidden')
// ---- Shared styles ----
const ROW = 'flex items-center gap-2 px-3 py-2 rounded-lg border border-border-light bg-surface-alt group'
const ICON_BTN = 'p-1 rounded text-content-muted hover:text-content-secondary hover:bg-surface transition-colors disabled:opacity-30'
const INPUT = 'flex-1 min-w-0 text-sm bg-transparent border-b border-transparent focus:border-accent focus:outline-none py-0.5 text-content'
return (
<div className="border border-border-default rounded-xl bg-surface shadow-sm">
{/* ------------------------------------------------------------------ */}
{/* Header */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default bg-surface-alt rounded-t-xl gap-4">
<div className="flex-1 min-w-0 space-y-1.5">
{/* Editable name */}
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="text-base font-semibold text-content bg-transparent border-b border-transparent focus:border-accent focus:outline-none w-full"
placeholder="Template name"
/>
{/* Category key (read-only) + active toggle */}
<div className="flex items-center gap-3">
<span className="text-xs text-content-muted font-mono">{template.category_key}</span>
<button
type="button"
onClick={() => setIsActive((v) => !v)}
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full transition-colors ${
isActive ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'
}`}
>
{isActive ? <ToggleRight size={13} /> : <ToggleLeft size={13} />}
{isActive ? 'Active' : 'Inactive'}
</button>
</div>
{/* Description */}
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs text-content-muted bg-transparent border-b border-transparent focus:border-accent focus:outline-none w-full"
placeholder="Description (optional)"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => saveMut.mutate()}
disabled={saveMut.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
<Save size={14} />
{saveMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
<div className="p-5 space-y-8">
{/* ---------------------------------------------------------------- */}
{/* Standard Fields */}
{/* ---------------------------------------------------------------- */}
<section>
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-content-secondary uppercase tracking-wide">
Standard Fields
</h3>
<p className="text-xs text-content-muted mt-0.5">
Rename, reorder, and set visibility for each column. Hidden fields are excluded from forms.
</p>
</div>
<button
type="button"
onClick={() => setShowHidden((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs text-content-muted hover:text-content px-2 py-1 rounded border border-border-default hover:bg-surface-hover transition-colors"
>
{showHidden ? <EyeOff size={12} /> : <Eye size={12} />}
{showHidden ? 'Hide hidden' : `Show hidden (${hiddenKeys.size})`}
</button>
</div>
<div className="space-y-1.5">
{visibleFields.map((field, i) => {
// Real index in fields array (needed for moveItem / updateField)
const realIdx = fields.indexOf(field)
const isHidden = field.visibility === 'hidden'
return (
<div
key={field.key}
className={`${ROW} ${isHidden ? 'opacity-50' : ''}`}
>
{/* Reorder */}
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className={ICON_BTN}
disabled={realIdx === 0}
onClick={() => setFields((f) => moveItem(f, realIdx, realIdx - 1))}
aria-label="Move up"
>
<ChevronUp size={12} />
</button>
<button
type="button"
className={ICON_BTN}
disabled={realIdx === fields.length - 1}
onClick={() => setFields((f) => moveItem(f, realIdx, realIdx + 1))}
aria-label="Move down"
>
<ChevronDown size={12} />
</button>
</div>
<GripVertical size={13} className="text-content-muted shrink-0" />
{/* Label */}
<input
value={field.label}
onChange={(e) => updateField(realIdx, { label: e.target.value })}
className={INPUT}
placeholder="Field label"
/>
{/* Key badge */}
<span className="hidden md:block text-xs text-content-muted font-mono w-48 shrink-0 truncate">
{field.key}
</span>
{/* Visibility */}
<VisibilityToggle
value={field.visibility}
onChange={(v) => updateField(realIdx, { visibility: v })}
/>
{/* Hide button */}
<button
type="button"
className={`${ICON_BTN} hover:text-red-500 hover:bg-red-50`}
onClick={() => removeField(realIdx)}
aria-label="Hide field"
title="Hide this field"
>
<EyeOff size={14} />
</button>
</div>
)
})}
</div>
{/* Restore hidden field */}
{availableToAdd.length > 0 && (
<div className="mt-3 flex items-center gap-2">
<select
value={newFieldKey}
onChange={(e) => setNewFieldKey(e.target.value)}
className="text-xs border border-border-default rounded px-2 py-1.5 text-content-secondary focus:outline-none focus:border-accent"
>
<option value="">Restore a hidden field</option>
{availableToAdd.map((d) => (
<option key={d.key} value={d.key}>
{fields.find((f) => f.key === d.key)?.label || d.defaultLabel}
</option>
))}
</select>
{newFieldKey && (
<button
type="button"
onClick={() => restoreField(newFieldKey)}
className="text-xs px-2 py-1.5 rounded bg-accent text-white hover:bg-accent-hover transition-colors"
>
Restore
</button>
)}
</div>
)}
</section>
{/* ---------------------------------------------------------------- */}
{/* Component Schema */}
{/* ---------------------------------------------------------------- */}
<section>
<div className="mb-3">
<h3 className="text-sm font-semibold text-content-secondary uppercase tracking-wide">
Component Schema
</h3>
<p className="text-xs text-content-muted mt-0.5">
Define the expected component types that appear as column pairs in the Excel file (cols 11+).
</p>
</div>
<div className="space-y-1.5">
{pairs.length === 0 && (
<p className="text-sm text-content-muted italic px-3">No component types defined.</p>
)}
{pairs.map((pair, i) => (
<div key={i} className={ROW}>
{/* Reorder */}
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className={ICON_BTN}
disabled={i === 0}
onClick={() => setPairs((p) => moveItem(p, i, i - 1))}
aria-label="Move up"
>
<ChevronUp size={12} />
</button>
<button
type="button"
className={ICON_BTN}
disabled={i === pairs.length - 1}
onClick={() => setPairs((p) => moveItem(p, i, i + 1))}
aria-label="Move down"
>
<ChevronDown size={12} />
</button>
</div>
<GripVertical size={13} className="text-content-muted shrink-0" />
{/* Index badge */}
<span className="text-xs text-content-muted font-mono w-6 text-center shrink-0">
{i + 1}
</span>
{/* Component type name */}
<input
value={pair.component_type}
onChange={(e) => updatePair(i, { component_type: e.target.value })}
placeholder="Component type name"
className={INPUT}
/>
{/* Required toggle */}
<button
type="button"
onClick={() => updatePair(i, { required: !pair.required })}
className={`px-2.5 py-1 rounded text-xs font-medium shrink-0 transition-colors ${
pair.required
? 'text-white'
: 'bg-surface-muted text-content-secondary hover:bg-surface-hover'
}`}
style={pair.required ? { backgroundColor: 'var(--color-accent)' } : undefined}
title="Toggle required"
>
{pair.required ? 'Required' : 'Optional'}
</button>
{/* Delete */}
<button
type="button"
className={`${ICON_BTN} hover:text-red-500 hover:bg-red-50`}
onClick={() => removePair(i)}
aria-label="Delete component"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addPair}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-accent hover:text-accent-hover px-3 py-1.5 rounded border border-border-default hover:bg-accent-light transition-colors"
>
<Plus size={13} />
Add component type
</button>
</section>
</div>
</div>
)
}