feat(csv-templates): Introduce CSV template analysis and validation features
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Added a new endpoint for analyzing uploaded CSV files, providing suggestions for field mappings and type conversions.
- Implemented validation for required field targets to ensure all mandatory fields are mapped correctly.
- Enhanced the admin CSV templates interface with new routes and navigation options in the frontend.
- Updated API utility functions to support the new CSV analysis functionality.
- Improved error handling for CSV uploads, including file size and row count checks.
This commit is contained in:
Lars 2026-04-10 06:39:41 +02:00
parent 338163ac0b
commit c10da55ec6
9 changed files with 1017 additions and 4 deletions

View File

@ -0,0 +1,179 @@
"""
Heuristische Vorschläge für CSV field_mappings / type_conversions (Admin-Editor, Issue #21).
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Mapping
from csv_parser.core import normalize_header_for_signature
from csv_parser.module_registry import get_module_definition
# Normalisierte Header-Fragmente → DB-Feld (Substring- oder exakter Norm-Vergleich)
_MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = {
"nutrition": {
"date": frozenset(
{"datum", "date", "tag", "day", "zeit", "timestamp", "uhrzeit", "monat", "jahr"}
),
"kcal": frozenset({"kcal", "kalorie", "calorie", "energie", "energy", "kj", "joule"}),
"protein_g": frozenset({"protein", "eiwei", "eiweiss"}),
"fat_g": frozenset({"fett", "fat", "lipid"}),
"carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}),
},
"weight": {
"date": frozenset({"datum", "date", "tag", "day", "zeit"}),
"weight": frozenset({"gewicht", "weight", "masse", "kg", "kilo"}),
"note": frozenset({"notiz", "note", "comment", "kommentar"}),
},
"blood_pressure": {
"measured_date": frozenset({"datum", "date", "tag", "day", "messdatum"}),
"measured_time": frozenset({"zeit", "time", "uhr", "uhrzeit"}),
"systolic": frozenset({"systol", "sys", "sbp", "oberdruck"}),
"diastolic": frozenset({"diastol", "dia", "dbp", "unterdruck"}),
"pulse": frozenset({"puls", "pulse", "hr", "herz", "bpm"}),
},
"activity": {
"date": frozenset({"datum", "date", "tag", "day"}),
"start_time": frozenset({"start", "beginn", "von"}),
"end_time": frozenset({"end", "ende", "bis", "stop"}),
"activity_type": frozenset({"workout", "training", "typ", "type", "art", "aktiv"}),
"duration_min": frozenset({"dauer", "duration", "min"}),
"distance_km": frozenset({"strecke", "distance", "km", "distanz"}),
"kcal_active": frozenset({"kcal", "kalorie", "energie", "active"}),
"hr_avg": frozenset({"puls", "heart", "hr", "bpm", "herzfrequenz"}),
},
}
_DEFAULT_TYPE_CONVERSIONS: dict[str, dict[str, dict[str, Any]]] = {
"nutrition": {
"date": {"type": "date", "format": "dd.mm.yyyy HH:MM", "extract": "date_only", "flexible": True},
"kcal": {"type": "float", "decimal_separator": "auto", "flexible": True},
"protein_g": {"type": "float", "decimal_separator": "auto", "flexible": True},
"fat_g": {"type": "float", "decimal_separator": "auto", "flexible": True},
"carbs_g": {"type": "float", "decimal_separator": "auto", "flexible": True},
},
"weight": {
"date": {"type": "date", "format": "dd.mm.yyyy", "flexible": True},
"weight": {"type": "float", "decimal_separator": "auto", "flexible": True},
"note": {"type": "string"},
},
"blood_pressure": {
"measured_date": {"type": "date", "format": "dd.mm.yyyy", "flexible": True},
"measured_time": {"type": "time", "format": "HH:MM", "flexible": True},
"systolic": {"type": "int", "flexible": True},
"diastolic": {"type": "int", "flexible": True},
"pulse": {"type": "int", "flexible": True},
},
"activity": {
"date": {"type": "date", "format": "yyyy-mm-dd", "flexible": True},
"start_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True},
"end_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True},
"activity_type": {"type": "string"},
"duration_min": {"type": "duration", "format": "HH:MM:SS", "target_unit": "minutes", "flexible": True},
"distance_km": {"type": "float", "decimal_separator": "auto", "flexible": True},
"kcal_active": {"type": "float", "decimal_separator": "auto", "flexible": True},
"hr_avg": {"type": "int", "flexible": True},
},
}
def _norm_key(header: str) -> str:
return normalize_header_for_signature(header)
def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | None:
"""Findet Ziel-Feld, wenn Seed-Key zu diesem Header passt (exakt oder normalisiert)."""
if header in seed_fm:
v = seed_fm[header]
if v and v not in ("-", "_skip"):
return v
nh = _norm_key(header)
if nh in seed_fm:
v = seed_fm[nh]
if v and v not in ("-", "_skip"):
return v
for sk, sv in seed_fm.items():
if not sv or sv in ("-", "_skip"):
continue
if _norm_key(str(sk)) == nh:
return sv
return None
def _alias_suggest(norm: str, module: str, used: set[str]) -> str | None:
aliases = _MODULE_HEADER_ALIASES.get(module, {})
mod = get_module_definition(module)
if not mod:
return None
field_order = list(mod["fields"].keys())
for db_field in field_order:
if db_field in used:
continue
tokens = aliases.get(db_field, frozenset())
nlow = norm.lower()
if nlow == db_field or nlow.replace("_", "") == db_field.replace("_", ""):
return db_field
for tok in tokens:
if len(tok) >= 2 and tok in nlow:
return db_field
if len(tok) >= 4 and tok in norm:
return db_field
return None
def suggest_field_mappings(
headers: list[str],
module: str,
seed_fm: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
Nutzt zuerst eine passende Seed-Vorlage, dann Alias-Heuristik.
"""
mod = get_module_definition(module)
if not mod:
return {h: "-" for h in headers}
fm: dict[str, str] = {h: "-" for h in headers}
used: set[str] = set()
if seed_fm:
for h in headers:
db = _match_seed_to_db_field(h, seed_fm)
if db and db not in used:
fm[h] = db
used.add(db)
for h in headers:
if fm[h] != "-":
continue
norm = _norm_key(h)
db = _alias_suggest(norm, module, used)
if db:
fm[h] = db
used.add(db)
return fm
def build_type_conversions_for_mapping(
module: str,
field_mappings: Mapping[str, str],
seed_tc: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
out: dict[str, Any] = {}
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
if seed_tc:
for k, v in seed_tc.items():
if k in targets and isinstance(v, dict):
out[k] = deepcopy(v)
for t in targets:
if t not in out and t in defaults:
out[t] = deepcopy(defaults[t])
return out

View File

@ -82,7 +82,19 @@ def validate_field_mappings(module: str, field_mappings: dict) -> None:
fields = cast(dict, mod["fields"])
allowed = set(fields.keys())
for _csv_col, db_field in field_mappings.items():
if db_field in ("", None, "-"):
if db_field in ("", None, "-", "_skip"):
continue
if db_field not in allowed:
raise ValueError(f"Ungültiges Zielfeld '{db_field}' für Modul '{module}'")
def validate_required_field_targets(module: str, field_mappings: dict) -> None:
"""Stellt sicher, dass jedes als required markierte Zielfeld mindestens einer Spalte zugeordnet ist."""
mod = get_module_definition(module)
if not mod:
raise ValueError(f"Unbekanntes Modul: {module}")
field_defs = cast(dict, mod["fields"])
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
for fname, finfo in field_defs.items():
if finfo.get("required") and fname not in targets:
raise ValueError(f"Pflicht-Zielfeld nicht zugeordnet: {fname}")

View File

@ -5,14 +5,26 @@ from __future__ import annotations
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from auth import require_admin
from db import get_db, get_cursor, r2d
from csv_parser.core import get_csv_import_limits
from csv_parser.module_registry import get_module_definition, validate_field_mappings
from csv_parser.core import (
column_signature,
decode_raw_bytes,
get_csv_import_limits,
headers_signature_match_score,
normalize_header_for_signature,
parse_csv_sample,
)
from csv_parser.mapping_suggest import build_type_conversions_for_mapping, suggest_field_mappings
from csv_parser.module_registry import (
get_module_definition,
validate_field_mappings,
validate_required_field_targets,
)
router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"])
@ -112,6 +124,127 @@ def list_system_templates(
return {"templates": [_row_full(m) for m in rows]}
@router.post("/analyze-upload")
async def admin_analyze_csv_for_template(
file: UploadFile = File(...),
module: str = Form(...),
delimiter: Optional[str] = Form(default=None),
seed_template_id: Optional[int] = Form(default=None),
session: dict = Depends(require_admin),
):
"""
CSV hochladen wie im Nutzer-Import: Spalten + Vorschau + Vorschläge für field_mappings
und type_conversions. Optional Seed-Vorlage (ID) oder beste Jaccard-Systemvorlage für das Modul.
"""
_ = session
if not get_module_definition(module):
raise HTTPException(400, f"Unbekanntes Modul: {module}")
raw = await file.read()
limits = _admin_csv_limits()
max_bytes = limits.get("max_file_bytes", 52_428_800)
if len(raw) > max_bytes:
raise HTTPException(
413,
f"Datei zu groß (max. {max_bytes} Bytes laut Systemkonfiguration)",
)
text = decode_raw_bytes(raw)
if not text.strip():
raise HTTPException(400, "Leere Datei")
max_rows = limits.get("max_rows_per_file", 50_000)
if text.count("\n") > max_rows + 5:
raise HTTPException(
413,
f"Zu viele Zeilen (>{max_rows}) laut Systemkonfiguration",
)
delim = delimiter if delimiter in (",", ";", "\t") else None
headers, sample_rows, used_delim = parse_csv_sample(text, delimiter=delim, max_data_rows=5)
if not headers:
raise HTTPException(400, "Keine Kopfzeile oder leeres CSV")
sig = column_signature(headers)
seed_row: dict | None = None
with get_db() as conn:
cur = get_cursor(conn)
if seed_template_id is not None:
cur.execute(
"""
SELECT * FROM csv_field_mappings
WHERE id = %s AND is_system = true AND profile_id IS NULL AND module = %s
""",
(seed_template_id, module),
)
seed_row = r2d(cur.fetchone())
if not seed_row:
raise HTTPException(404, "Seed-Vorlage nicht gefunden oder falsches Modul")
else:
cur.execute(
"""
SELECT * FROM csv_field_mappings
WHERE is_system = true AND profile_id IS NULL AND module = %s
""",
(module,),
)
rows = [r2d(r) for r in cur.fetchall()]
best: dict | None = None
best_score = -1.0
for t in rows:
t_sig = list(t.get("column_signature") or [])
t_norm = sorted({normalize_header_for_signature(str(s)) for s in t_sig})
score = headers_signature_match_score(sig, t_norm)
if score > best_score:
best_score = score
best = t
if best and best_score > 0:
seed_row = best
seed_fm = (seed_row or {}).get("field_mappings") or {}
if isinstance(seed_fm, str):
seed_fm = {}
seed_tc = (seed_row or {}).get("type_conversions")
if not isinstance(seed_tc, dict):
seed_tc = {}
field_mappings = suggest_field_mappings(headers, module, seed_fm if seed_fm else None)
type_conversions = build_type_conversions_for_mapping(module, field_mappings, seed_tc if seed_tc else None)
seed_meta = None
if seed_row:
t_sig = [normalize_header_for_signature(str(s)) for s in (seed_row.get("column_signature") or [])]
seed_meta = {
"id": seed_row["id"],
"mapping_name": seed_row.get("mapping_name"),
"confidence": round(headers_signature_match_score(sig, sorted(set(t_sig))), 4)
if t_sig
else 0.0,
}
return {
"filename": file.filename,
"module": module,
"delimiter": used_delim,
"encoding": "utf-8",
"columns": headers,
"column_signature_normalized": sig,
"sample_rows": sample_rows,
"seed_template": seed_meta,
"field_mappings": field_mappings,
"type_conversions": type_conversions,
}
def _admin_csv_limits() -> dict[str, int]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT value FROM system_config WHERE key = %s", ("csv_import",))
row = cur.fetchone()
return get_csv_import_limits(r2d(row) if row else None)
@router.get("/{template_id}")
def get_system_template(template_id: int, session: dict = Depends(require_admin)):
with get_db() as conn:
@ -132,6 +265,7 @@ def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depend
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
try:
validate_field_mappings(body.module, body.field_mappings)
validate_required_field_targets(body.module, body.field_mappings)
except ValueError as e:
raise HTTPException(400, str(e))
@ -187,6 +321,7 @@ def update_system_template(
if "field_mappings" in patch:
try:
validate_field_mappings(existing["module"], fm)
validate_required_field_targets(existing["module"], fm)
except ValueError as e:
raise HTTPException(400, str(e))

View File

@ -0,0 +1,42 @@
"""Tests für CSV mapping_suggest (Issue #21)."""
from csv_parser.mapping_suggest import (
build_type_conversions_for_mapping,
suggest_field_mappings,
)
def test_suggest_from_fddb_seed():
headers = ["Datum Tag Monat Jahr Stunde Minute", "kJ", "Fett (g)", "KH (g)", "Protein (g)"]
seed = {
"datum_tag_monat_jahr_stunde_minute": "date",
"kj": "kcal",
"fett_g": "fat_g",
"kh_g": "carbs_g",
"protein_g": "protein_g",
}
fm = suggest_field_mappings(headers, "nutrition", seed)
assert fm["Datum Tag Monat Jahr Stunde Minute"] == "date"
assert fm["kJ"] == "kcal"
def test_suggest_aliases_without_seed():
fm = suggest_field_mappings(
["Date", "Calories", "Protein (g)", "Fat (g)", "Carbs"],
"nutrition",
None,
)
assert fm["Date"] == "date"
assert fm["Calories"] == "kcal"
assert fm["Protein (g)"] == "protein_g"
def test_type_conversions_merge():
fm = {"Col A": "date", "Col B": "kcal"}
seed_tc = {
"kcal": {"type": "float", "conversion_factor": 0.239, "decimal_separator": "auto"},
}
tc = build_type_conversions_for_mapping("nutrition", fm, seed_tc)
assert "kcal" in tc
assert tc["kcal"].get("conversion_factor") == 0.239
assert "date" in tc

View File

@ -54,6 +54,8 @@ import VitalsPage from './pages/VitalsPage'
import GoalsPage from './pages/GoalsPage'
import CustomGoalsPage from './pages/CustomGoalsPage'
import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage'
import AdminCsvTemplateEditorPage from './pages/AdminCsvTemplateEditorPage'
import WorkflowEditorPage from './pages/WorkflowEditorPage'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
@ -258,6 +260,8 @@ function AppShell() {
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
<Route path="csv-templates" element={<AdminCsvTemplatesPage />} />
<Route path="csv-templates/:id" element={<AdminCsvTemplateEditorPage />} />
</Route>
</Route>
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>

View File

@ -125,6 +125,11 @@ export const ADMIN_GROUPS = [
label: 'Produkt-Dashboard (Standard)',
description: 'Globales Standard-Layout der Startseite (DB oder Code-Fallback).',
},
{
to: '/admin/csv-templates',
label: 'CSV-Import-Vorlagen',
description: 'System-Vorlagen per CSV-Analyse anlernen, Spalten zuordnen, pflegen.',
},
],
},
]

View File

@ -0,0 +1,503 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Loader2, Save, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
const MODULE_LABEL = {
nutrition: 'Ernährung',
weight: 'Gewicht',
blood_pressure: 'Blutdruck',
activity: 'Aktivität',
}
function SampleTable({ sampleRows, columns }) {
if (!sampleRows?.length || !columns?.length) return null
const showCols = columns.slice(0, 8)
return (
<div style={{ overflowX: 'auto', marginTop: 12 }}>
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
<thead>
<tr>
{showCols.map((c) => (
<th
key={c}
style={{
textAlign: 'left',
padding: '8px 6px',
borderBottom: '1px solid var(--border)',
color: 'var(--text2)',
whiteSpace: 'nowrap',
}}
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{sampleRows.slice(0, 5).map((row, i) => (
<tr key={i}>
{showCols.map((c) => (
<td
key={c}
style={{
padding: '6px',
borderBottom: '1px solid var(--border)',
maxWidth: 140,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{row[c] ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function AdminCsvTemplateEditorPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const isNew = routeId === 'new'
const templateId = isNew ? null : Number(routeId)
const [modules, setModules] = useState([])
const [module, setModule] = useState('nutrition')
const [mappingName, setMappingName] = useState('')
const [description, setDescription] = useState('')
const [delimiter, setDelimiter] = useState(';')
const [encoding, setEncoding] = useState('utf-8')
const [hasHeader, setHasHeader] = useState(true)
const [columnSignature, setColumnSignature] = useState([])
const [columns, setColumns] = useState([])
const [fieldMappings, setFieldMappings] = useState({})
const [typeConversionsText, setTypeConversionsText] = useState('{}')
const [sampleRows, setSampleRows] = useState([])
const [seedHint, setSeedHint] = useState(null)
const [file, setFile] = useState(null)
const [delimiterOverride, setDelimiterOverride] = useState('')
const [seedTemplateId, setSeedTemplateId] = useState('')
const [seedOptions, setSeedOptions] = useState([])
const [loading, setLoading] = useState(!isNew)
const [analyzing, setAnalyzing] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module])
const targetOptions = useMemo(() => {
if (!modMeta?.fields) return []
return Object.entries(modMeta.fields).map(([key, meta]) => ({
value: key,
label: `${key}${meta.required ? ' *' : ''}`,
}))
}, [modMeta])
const requiredTargets = useMemo(() => {
if (!modMeta?.fields) return []
return Object.entries(modMeta.fields)
.filter(([, v]) => v.required)
.map(([k]) => k)
}, [modMeta])
useEffect(() => {
api
.getCsvModules()
.then((r) => setModules(r.modules || []))
.catch(() => {})
}, [])
useEffect(() => {
if (!module) return
api
.adminListCsvTemplates(module)
.then((d) => setSeedOptions(d.templates || []))
.catch(() => setSeedOptions([]))
}, [module])
useEffect(() => {
if (isNew || !templateId) return
let ok = true
setLoading(true)
setError(null)
api
.adminGetCsvTemplate(templateId)
.then((t) => {
if (!ok) return
setModule(t.module)
setMappingName(t.mapping_name || '')
setDescription(t.description || '')
setDelimiter(t.delimiter || ',')
setEncoding(t.encoding || 'utf-8')
setHasHeader(!!t.has_header)
setColumnSignature(t.column_signature || [])
const fm = t.field_mappings || {}
setFieldMappings(fm)
setColumns(Object.keys(fm))
setTypeConversionsText(JSON.stringify(t.type_conversions || {}, null, 2))
setSampleRows([])
setSeedHint(null)
})
.catch((e) => {
if (ok) setError(e.message)
})
.finally(() => {
if (ok) setLoading(false)
})
return () => {
ok = false
}
}, [isNew, templateId])
const assignedTargets = useMemo(() => {
return new Set(
Object.values(fieldMappings).filter((v) => v && v !== '-' && v !== '_skip'),
)
}, [fieldMappings])
const missingRequired = useMemo(() => {
return requiredTargets.filter((r) => !assignedTargets.has(r))
}, [requiredTargets, assignedTargets])
const handleAnalyze = async () => {
if (!file) {
setError('Bitte eine CSV-Datei wählen.')
return
}
setAnalyzing(true)
setError(null)
try {
const delim = delimiterOverride || null
const seed = seedTemplateId === '' ? null : Number(seedTemplateId)
const res = await api.adminAnalyzeCsvTemplate(file, module, delim, seed)
setColumns(res.columns || [])
setColumnSignature(res.column_signature_normalized || [])
setFieldMappings(res.field_mappings || {})
setTypeConversionsText(JSON.stringify(res.type_conversions || {}, null, 2))
setSampleRows(res.sample_rows || [])
setDelimiter(res.delimiter || ';')
setEncoding(res.encoding || 'utf-8')
setSeedHint(res.seed_template || null)
} catch (e) {
setError(e.message || 'Analyse fehlgeschlagen')
} finally {
setAnalyzing(false)
}
}
const updateMapping = (col, dbField) => {
setFieldMappings((prev) => ({ ...prev, [col]: dbField || '-' }))
}
const handleSave = async () => {
setError(null)
let tc = null
try {
tc = JSON.parse(typeConversionsText || '{}')
if (tc !== null && typeof tc !== 'object') throw new Error()
} catch {
setError('type_conversions: ungültiges JSON.')
return
}
if (!mappingName.trim()) {
setError('Bitte einen Namen für die Vorlage eingeben.')
return
}
if (!columns.length || !Object.keys(fieldMappings).length) {
setError('Keine Spalten-Zuordnung: CSV analysieren oder Vorlage laden.')
return
}
if (missingRequired.length) {
setError(`Pflicht-Zielfelder fehlen: ${missingRequired.join(', ')}`)
return
}
const payload = {
module,
mapping_name: mappingName.trim(),
description: description.trim() || null,
column_signature: columnSignature.length ? columnSignature : null,
delimiter,
encoding: encoding || 'utf-8',
has_header: hasHeader,
field_mappings: fieldMappings,
type_conversions: tc,
}
if (!payload.column_signature?.length) {
setError('column_signature fehlt — bitte CSV erneut analysieren.')
return
}
setSaving(true)
try {
if (isNew) {
await api.adminCreateCsvTemplate(payload)
} else {
const { module: _m, ...patch } = payload
await api.adminUpdateCsvTemplate(templateId, patch)
}
navigate('/admin/csv-templates')
} catch (e) {
setError(e.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (isNew || !templateId) return
if (!confirm('System-Vorlage wirklich löschen?')) return
setError(null)
try {
await api.adminDeleteCsvTemplate(templateId)
navigate('/admin/csv-templates')
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen')
}
}
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Loader2 size={28} className="spin" style={{ animation: 'spin 0.8s linear infinite' }} />
</div>
)
}
return (
<div style={{ padding: '16px 16px 96px', maxWidth: 920, margin: '0 auto' }}>
<Link
to="/admin/csv-templates"
className="btn btn-secondary"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 16, textDecoration: 'none' }}
>
<ArrowLeft size={18} /> Zur Liste
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<FileSpreadsheet size={26} strokeWidth={2} />
{isNew ? 'Neue CSV-Vorlage' : 'Vorlage bearbeiten'}
</h1>
{error && (
<div className="card" style={{ padding: 12, borderColor: 'var(--danger)', color: 'var(--danger)', marginBottom: 16 }}>
{error}
</div>
)}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">Modul</div>
<select
className="form-input"
value={module}
disabled={!isNew}
onChange={(e) => setModule(e.target.value)}
style={{ width: '100%', marginTop: 8 }}
>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{MODULE_LABEL[m.id] || m.id}
</option>
))}
</select>
{!isNew && (
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 8 }}>
Modul bestehender Vorlagen kann nicht geändert werden.
</p>
)}
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">1. Beispiel-CSV (wie Import)</div>
<input
type="file"
accept=".csv,text/csv"
className="form-input"
style={{ marginTop: 8, width: '100%' }}
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<div style={{ marginTop: 12, display: 'grid', gap: 12 }}>
<label style={{ fontSize: 14, color: 'var(--text2)' }}>
Trennzeichen (optional, sonst automatisch):
<select
className="form-input"
style={{ width: '100%', marginTop: 6 }}
value={delimiterOverride}
onChange={(e) => setDelimiterOverride(e.target.value)}
>
<option value="">Auto</option>
<option value=";">Semikolon</option>
<option value=",">Komma</option>
<option value="\t">Tab</option>
</select>
</label>
<label style={{ fontSize: 14, color: 'var(--text2)' }}>
Optional: feste Seed-Vorlage für Vorschläge:
<select
className="form-input"
style={{ width: '100%', marginTop: 6 }}
value={seedTemplateId}
onChange={(e) => setSeedTemplateId(e.target.value)}
>
<option value="">Beste passende System-Vorlage (Jaccard)</option>
{seedOptions.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.mapping_name}
</option>
))}
</select>
</label>
</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 16, width: '100%' }}
disabled={!file || analyzing}
onClick={handleAnalyze}
>
{analyzing ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Analysiere
</>
) : (
'CSV analysieren & Vorschläge'
)}
</button>
{seedHint && (
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 12 }}>
Seed: <strong>{seedHint.mapping_name}</strong> · Übereinstimmung ca.{' '}
{Math.round((seedHint.confidence || 0) * 100)} %
</p>
)}
{sampleRows.length > 0 && <SampleTable sampleRows={sampleRows} columns={columns} />}
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">2. Stammdaten</div>
<label className="form-label" style={{ marginTop: 12 }}>
Name *
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={mappingName}
onChange={(e) => setMappingName(e.target.value)}
placeholder="z. B. FDDB Export 2026"
/>
<label className="form-label" style={{ marginTop: 12 }}>
Beschreibung
</label>
<textarea
className="form-input"
style={{ width: '100%', minHeight: 64 }}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<label>
<span className="form-label">Trennzeichen (gespeichert)</span>
<select className="form-input" style={{ width: '100%', marginTop: 6 }} value={delimiter} onChange={(e) => setDelimiter(e.target.value)}>
<option value=";">Semikolon</option>
<option value=",">Komma</option>
<option value="\t">Tab</option>
</select>
</label>
<label>
<span className="form-label">Kopfzeile</span>
<select
className="form-input"
style={{ width: '100%', marginTop: 6 }}
value={hasHeader ? 'yes' : 'no'}
onChange={(e) => setHasHeader(e.target.value === 'yes')}
>
<option value="yes">Ja</option>
<option value="no">Nein (nicht unterstützt im Import)</option>
</select>
</label>
</div>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3. Spalten Zielfelder (* = Pflicht)</div>
{!columns.length ? (
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 8 }}>Nach CSV-Analyse erscheinen die Zeilen hier.</p>
) : (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{columns.map((col) => (
<div
key={col}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(140px, 200px)',
gap: 10,
alignItems: 'center',
}}
>
<code style={{ fontSize: 12, wordBreak: 'break-word', color: 'var(--text2)' }}>{col}</code>
<select
className="form-input"
value={fieldMappings[col] || '-'}
onChange={(e) => updateMapping(col, e.target.value)}
>
<option value="-"> ignorieren</option>
{targetOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
))}
</div>
)}
{missingRequired.length > 0 && (
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 12 }}>
Noch zuzuweisen: {missingRequired.join(', ')}
</p>
)}
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">4. type_conversions (JSON)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
Vom Vorschlag übernommen; bei Bedarf manuell anpassen (z. B. kJ-Faktor, Datumsformat).
</p>
<textarea
className="form-input"
style={{ width: '100%', minHeight: 200, marginTop: 8, fontFamily: 'monospace', fontSize: 12 }}
value={typeConversionsText}
onChange={(e) => setTypeConversionsText(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave} style={{ flex: 1, minWidth: 160 }}>
{saving ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Speichern
</>
) : (
<>
<Save size={18} style={{ marginRight: 8 }} />
Speichern
</>
)}
</button>
{!isNew && (
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}>
<Trash2 size={18} style={{ marginRight: 8 }} />
Löschen
</button>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Plus, Pencil } from 'lucide-react'
import { api } from '../utils/api'
const MODULE_LABEL = {
nutrition: 'Ernährung',
weight: 'Gewicht',
blood_pressure: 'Blutdruck',
activity: 'Aktivität',
}
export default function AdminCsvTemplatesPage() {
const [templates, setTemplates] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [filterModule, setFilterModule] = useState('')
useEffect(() => {
let ok = true
setLoading(true)
setError(null)
api
.adminListCsvTemplates(filterModule || null)
.then((d) => {
if (ok) setTemplates(d.templates || [])
})
.catch((e) => {
if (ok) setError(e.message)
})
.finally(() => {
if (ok) setLoading(false)
})
return () => {
ok = false
}
}, [filterModule])
return (
<div style={{ padding: '16px 16px 96px', maxWidth: 900, margin: '0 auto' }}>
<Link
to="/admin"
className="btn btn-secondary"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 16, textDecoration: 'none' }}
>
<ArrowLeft size={18} /> Admin
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<FileSpreadsheet size={26} strokeWidth={2} />
CSV-Import-Vorlagen (System)
</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.55, marginBottom: 20 }}>
Systemweite Vorlagen für den Universal-CSV-Import. Neue Vorlagen entstehen aus einer Beispiel-CSV inkl.
Spaltenzuordnung analog zur Import-Seite.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', marginBottom: 20 }}>
<Link to="/admin/csv-templates/new" className="btn btn-primary" style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Plus size={18} /> Neue Vorlage
</Link>
<label style={{ fontSize: 14, color: 'var(--text2)', display: 'flex', alignItems: 'center', gap: 8 }}>
Modul:
<select
className="form-input"
value={filterModule}
onChange={(e) => setFilterModule(e.target.value)}
style={{ minWidth: 160 }}
>
<option value="">Alle</option>
<option value="nutrition">Ernährung</option>
<option value="weight">Gewicht</option>
<option value="blood_pressure">Blutdruck</option>
<option value="activity">Aktivität</option>
</select>
</label>
</div>
{error && (
<div className="card" style={{ padding: 12, borderColor: 'var(--danger)', color: 'var(--danger)', marginBottom: 16 }}>
{error}
</div>
)}
{loading ? (
<div className="spinner" style={{ width: 32, height: 32, margin: 24 }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{templates.length === 0 ? (
<div className="card" style={{ padding: 16, color: 'var(--text2)' }}>
Keine Vorlagen. Neue Vorlage legt eine System-Vorlage aus einer CSV an.
</div>
) : (
templates.map((t) => (
<div key={t.id} className="card" style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div>
<div style={{ fontWeight: 600 }}>
{MODULE_LABEL[t.module] || t.module}: {t.mapping_name}
</div>
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 6 }}>
{t.description || '—'} · Trenner: <code>{t.delimiter}</code> · Spalten-Signatur:{' '}
{(t.column_signature || []).length} Felder
</div>
</div>
<Link
to={`/admin/csv-templates/${t.id}`}
className="btn btn-secondary"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }}
>
<Pencil size={16} /> Bearbeiten
</Link>
</div>
</div>
))
)}
</div>
)}
</div>
)
}

View File

@ -524,6 +524,18 @@ export const api = {
},
adminListCsvTemplates: (module = null) =>
req(module ? `/admin/csv-templates?module=${encodeURIComponent(module)}` : '/admin/csv-templates'),
/** CSV für Admin-Editor analysieren (Vorschlags-Mappings, wie Nutzer-Import) */
adminAnalyzeCsvTemplate: async (file, module, delimiter = null, seedTemplateId = null) => {
const fd = new FormData()
fd.append('file', file)
fd.append('module', module)
if (delimiter) fd.append('delimiter', delimiter)
if (seedTemplateId != null && seedTemplateId !== '') fd.append('seed_template_id', String(seedTemplateId))
const res = await fetch(BASE + '/admin/csv-templates/analyze-upload', { method: 'POST', headers: hdrs(), body: fd })
const j = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(j.detail || res.statusText || 'Analyse fehlgeschlagen')
return j
},
adminGetCsvTemplate: (id) => req(`/admin/csv-templates/${id}`),
adminCreateCsvTemplate: (d) => req('/admin/csv-templates', json(d)),
adminUpdateCsvTemplate: (id, d) => req(`/admin/csv-templates/${id}`, jput(d)),