2026-07-02 10:10:06 +02:00
|
|
|
|
import { useEffect, useState } from 'react'
|
2026-07-03 13:54:19 +02:00
|
|
|
|
import { db, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem } from '../db'
|
2026-07-02 10:10:06 +02:00
|
|
|
|
|
|
|
|
|
|
export default function FeedbackStructureConfig() {
|
|
|
|
|
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
|
|
|
|
|
const [categories, setCategories] = useState<FeedbackCategory[]>([])
|
|
|
|
|
|
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
|
|
|
|
|
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
|
|
|
|
|
const [expandedCat, setExpandedCat] = useState<Set<number>>(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
// Neue Einträge
|
|
|
|
|
|
const [newDimName, setNewDimName] = useState('')
|
|
|
|
|
|
const [newCatName, setNewCatName] = useState<Record<number, string>>({})
|
|
|
|
|
|
const [newItemName, setNewItemName] = useState<Record<number, string>>({})
|
|
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
2026-07-03 13:54:19 +02:00
|
|
|
|
const [dims, cats, itms] = await Promise.all([
|
2026-07-02 10:10:06 +02:00
|
|
|
|
db.feedbackDimensions.orderBy('order').toArray(),
|
|
|
|
|
|
db.feedbackCategories.orderBy('order').toArray(),
|
|
|
|
|
|
db.feedbackCriterionItems.orderBy('order').toArray(),
|
|
|
|
|
|
])
|
|
|
|
|
|
setDimensions(dims)
|
|
|
|
|
|
setCategories(cats)
|
|
|
|
|
|
setItems(itms)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
|
|
|
|
|
|
|
|
// ── Dimensionen ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const addDimension = async () => {
|
|
|
|
|
|
if (!newDimName.trim()) return
|
|
|
|
|
|
const maxOrder = dimensions.reduce((m, d) => Math.max(m, d.order), 0)
|
|
|
|
|
|
const id = await db.feedbackDimensions.add({ name: newDimName.trim(), order: maxOrder + 1 })
|
|
|
|
|
|
setNewDimName('')
|
|
|
|
|
|
await load()
|
|
|
|
|
|
setExpanded(prev => new Set([...prev, id]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateDimension = async (id: number, name: string) => {
|
|
|
|
|
|
await db.feedbackDimensions.update(id, { name })
|
|
|
|
|
|
setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deleteDimension = async (id: number) => {
|
|
|
|
|
|
const cats = await db.feedbackCategories.where('dimensionId').equals(id).toArray()
|
|
|
|
|
|
for (const cat of cats) {
|
|
|
|
|
|
await db.feedbackCriterionItems.where('categoryId').equals(cat.id!).delete()
|
|
|
|
|
|
}
|
|
|
|
|
|
await db.feedbackCategories.where('dimensionId').equals(id).delete()
|
|
|
|
|
|
await db.feedbackDimensions.delete(id)
|
|
|
|
|
|
await load()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const moveDim = async (dim: FeedbackDimension, dir: -1 | 1) => {
|
|
|
|
|
|
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
|
|
|
|
|
|
const idx = sorted.findIndex(d => d.id === dim.id)
|
|
|
|
|
|
const other = sorted[idx + dir]
|
|
|
|
|
|
if (!other) return
|
|
|
|
|
|
await db.feedbackDimensions.update(dim.id!, { order: other.order })
|
|
|
|
|
|
await db.feedbackDimensions.update(other.id!, { order: dim.order })
|
|
|
|
|
|
await load()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Kategorien ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const addCategory = async (dimId: number) => {
|
|
|
|
|
|
const name = newCatName[dimId]?.trim()
|
|
|
|
|
|
if (!name) return
|
|
|
|
|
|
const dimCats = categories.filter(c => c.dimensionId === dimId)
|
|
|
|
|
|
const maxOrder = dimCats.reduce((m, c) => Math.max(m, c.order), 0)
|
|
|
|
|
|
const id = await db.feedbackCategories.add({ dimensionId: dimId, name, order: maxOrder + 1 })
|
|
|
|
|
|
setNewCatName(prev => ({ ...prev, [dimId]: '' }))
|
|
|
|
|
|
await load()
|
|
|
|
|
|
setExpandedCat(prev => new Set([...prev, id]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateCategory = async (id: number, name: string) => {
|
|
|
|
|
|
await db.feedbackCategories.update(id, { name })
|
|
|
|
|
|
setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deleteCategory = async (id: number) => {
|
|
|
|
|
|
await db.feedbackCriterionItems.where('categoryId').equals(id).delete()
|
|
|
|
|
|
await db.feedbackCategories.delete(id)
|
|
|
|
|
|
await load()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Kriterium-Items ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const addItem = async (catId: number) => {
|
|
|
|
|
|
const name = newItemName[catId]?.trim()
|
|
|
|
|
|
if (!name) return
|
|
|
|
|
|
const catItems = items.filter(i => i.categoryId === catId)
|
|
|
|
|
|
const maxOrder = catItems.reduce((m, i) => Math.max(m, i.order), 0)
|
|
|
|
|
|
await db.feedbackCriterionItems.add({ categoryId: catId, name, order: maxOrder + 1 })
|
|
|
|
|
|
setNewItemName(prev => ({ ...prev, [catId]: '' }))
|
|
|
|
|
|
await load()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateItem = async (id: number, name: string) => {
|
|
|
|
|
|
await db.feedbackCriterionItems.update(id, { name })
|
|
|
|
|
|
setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deleteItem = async (id: number) => {
|
|
|
|
|
|
await db.feedbackCriterionItems.delete(id)
|
|
|
|
|
|
setItems(prev => prev.filter(i => i.id !== id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateItemWeight = async (id: number, weight: number) => {
|
|
|
|
|
|
await db.feedbackCriterionItems.update(id, { weight })
|
|
|
|
|
|
setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<h2 className="text-lg font-bold text-gray-800">Feedback-Dimensionen & Kategorien</h2>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Neue Dimension */}
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
2026-07-02 10:18:51 +02:00
|
|
|
|
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
|
2026-07-02 10:10:06 +02:00
|
|
|
|
placeholder="Neue Dimension…"
|
|
|
|
|
|
value={newDimName}
|
|
|
|
|
|
onChange={e => setNewDimName(e.target.value)}
|
|
|
|
|
|
onKeyDown={e => e.key === 'Enter' && addDimension()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button onClick={addDimension}
|
2026-07-02 10:18:51 +02:00
|
|
|
|
className="px-4 py-2 bg-brand text-white text-sm rounded-lg font-medium">
|
2026-07-02 10:10:06 +02:00
|
|
|
|
+ Dimension
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Dimensionen */}
|
|
|
|
|
|
{sorted.map((dim, di) => {
|
|
|
|
|
|
const isOpen = expanded.has(dim.id!)
|
|
|
|
|
|
const dimCats = [...categories].filter(c => c.dimensionId === dim.id).sort((a, b) => a.order - b.order)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={dim.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
|
|
|
|
|
{/* Dimension-Header */}
|
|
|
|
|
|
<div className="flex items-center gap-2 p-3 bg-gray-50 border-b border-gray-100">
|
|
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
|
|
|
|
<button onClick={() => moveDim(dim, -1)} disabled={di === 0}
|
|
|
|
|
|
className="text-xs text-gray-400 hover:text-gray-600 leading-none disabled:opacity-30">▲</button>
|
|
|
|
|
|
<button onClick={() => moveDim(dim, 1)} disabled={di === sorted.length - 1}
|
|
|
|
|
|
className="text-xs text-gray-400 hover:text-gray-600 leading-none disabled:opacity-30">▼</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<input
|
2026-07-02 10:18:51 +02:00
|
|
|
|
className="flex-1 bg-transparent font-semibold text-sm text-gray-800 focus:outline-none border-b border-transparent focus:border-brand"
|
2026-07-02 10:10:06 +02:00
|
|
|
|
value={dim.name}
|
|
|
|
|
|
onChange={e => updateDimension(dim.id!, e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button onClick={() => setExpanded(prev => {
|
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
|
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
|
|
|
|
|
return next
|
|
|
|
|
|
})} className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
|
|
|
|
|
<button onClick={() => deleteDimension(dim.id!)}
|
|
|
|
|
|
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{isOpen && (
|
|
|
|
|
|
<div className="p-3 space-y-3">
|
|
|
|
|
|
{dimCats.map(cat => {
|
|
|
|
|
|
const isCatOpen = expandedCat.has(cat.id!)
|
|
|
|
|
|
const catItems = [...items].filter(i => i.categoryId === cat.id).sort((a, b) => a.order - b.order)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={cat.id} className="border border-gray-100 rounded-lg overflow-hidden">
|
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50">
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="flex-1 bg-transparent text-sm text-gray-700 focus:outline-none"
|
|
|
|
|
|
value={cat.name}
|
|
|
|
|
|
onChange={e => updateCategory(cat.id!, e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button onClick={() => setExpandedCat(prev => {
|
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
|
if (next.has(cat.id!)) next.delete(cat.id!); else next.add(cat.id!)
|
|
|
|
|
|
return next
|
|
|
|
|
|
})} className="text-xs text-gray-400">{isCatOpen ? '▲' : '▼'}</button>
|
|
|
|
|
|
<button onClick={() => deleteCategory(cat.id!)}
|
|
|
|
|
|
className="text-xs text-red-300 hover:text-red-500">🗑</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{isCatOpen && (
|
|
|
|
|
|
<div className="p-2 space-y-1.5">
|
|
|
|
|
|
{catItems.map(item => (
|
|
|
|
|
|
<div key={item.id} className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-gray-300 text-xs">·</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="flex-1 text-xs text-gray-600 border-b border-transparent focus:border-gray-300 focus:outline-none bg-transparent py-0.5"
|
|
|
|
|
|
value={item.name}
|
|
|
|
|
|
onChange={e => updateItem(item.id!, e.target.value)}
|
|
|
|
|
|
/>
|
2026-07-03 11:56:22 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="number" min={0} max={2} step={0.1}
|
2026-07-02 10:10:06 +02:00
|
|
|
|
value={item.weight ?? 1}
|
|
|
|
|
|
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
|
|
|
|
|
|
title="Gewichtung"
|
2026-07-03 11:56:22 +02:00
|
|
|
|
className="w-14 text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand"
|
|
|
|
|
|
/>
|
2026-07-02 10:10:06 +02:00
|
|
|
|
<button onClick={() => deleteItem(item.id!)}
|
|
|
|
|
|
className="text-xs text-red-200 hover:text-red-400">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<div className="flex gap-1.5 mt-1">
|
|
|
|
|
|
<input
|
2026-07-02 10:18:51 +02:00
|
|
|
|
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand"
|
2026-07-02 10:10:06 +02:00
|
|
|
|
placeholder="Neues Kriterium…"
|
|
|
|
|
|
value={newItemName[cat.id!] ?? ''}
|
|
|
|
|
|
onChange={e => setNewItemName(p => ({ ...p, [cat.id!]: e.target.value }))}
|
|
|
|
|
|
onKeyDown={e => e.key === 'Enter' && addItem(cat.id!)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button onClick={() => addItem(cat.id!)}
|
|
|
|
|
|
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 text-xs rounded">+</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Neue Kategorie */}
|
|
|
|
|
|
<div className="flex gap-1.5">
|
|
|
|
|
|
<input
|
2026-07-02 10:18:51 +02:00
|
|
|
|
className="flex-1 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-brand"
|
2026-07-02 10:10:06 +02:00
|
|
|
|
placeholder="Neue Kategorie…"
|
|
|
|
|
|
value={newCatName[dim.id!] ?? ''}
|
|
|
|
|
|
onChange={e => setNewCatName(p => ({ ...p, [dim.id!]: e.target.value }))}
|
|
|
|
|
|
onKeyDown={e => e.key === 'Enter' && addCategory(dim.id!)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button onClick={() => addCategory(dim.id!)}
|
|
|
|
|
|
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-600 text-sm rounded-lg">
|
|
|
|
|
|
+ Kat.
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|