From c10da55ec662b2064d977847bf5e6246a4ddf686 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 10 Apr 2026 06:39:41 +0200 Subject: [PATCH] feat(csv-templates): Introduce CSV template analysis and validation features - 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. --- backend/csv_parser/mapping_suggest.py | 179 +++++++ backend/csv_parser/module_registry.py | 14 +- backend/routers/admin_csv_templates.py | 141 ++++- backend/tests/test_mapping_suggest.py | 42 ++ frontend/src/App.jsx | 4 + frontend/src/config/adminNav.js | 5 + .../src/pages/AdminCsvTemplateEditorPage.jsx | 503 ++++++++++++++++++ frontend/src/pages/AdminCsvTemplatesPage.jsx | 121 +++++ frontend/src/utils/api.js | 12 + 9 files changed, 1017 insertions(+), 4 deletions(-) create mode 100644 backend/csv_parser/mapping_suggest.py create mode 100644 backend/tests/test_mapping_suggest.py create mode 100644 frontend/src/pages/AdminCsvTemplateEditorPage.jsx create mode 100644 frontend/src/pages/AdminCsvTemplatesPage.jsx diff --git a/backend/csv_parser/mapping_suggest.py b/backend/csv_parser/mapping_suggest.py new file mode 100644 index 0000000..bc13203 --- /dev/null +++ b/backend/csv_parser/mapping_suggest.py @@ -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 diff --git a/backend/csv_parser/module_registry.py b/backend/csv_parser/module_registry.py index c433a00..fb31104 100644 --- a/backend/csv_parser/module_registry.py +++ b/backend/csv_parser/module_registry.py @@ -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}") diff --git a/backend/routers/admin_csv_templates.py b/backend/routers/admin_csv_templates.py index 5fe1ca4..1c55871 100644 --- a/backend/routers/admin_csv_templates.py +++ b/backend/routers/admin_csv_templates.py @@ -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)) diff --git a/backend/tests/test_mapping_suggest.py b/backend/tests/test_mapping_suggest.py new file mode 100644 index 0000000..86c98c8 --- /dev/null +++ b/backend/tests/test_mapping_suggest.py @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2e85b01..f9a5fd2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { }/> }/> }/> + } /> + } /> }/> diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index 0d15fd0..d87cf5c 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -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.', + }, ], }, ] diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx new file mode 100644 index 0000000..5d8d94d --- /dev/null +++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx @@ -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 ( +
+ + + + {showCols.map((c) => ( + + ))} + + + + {sampleRows.slice(0, 5).map((row, i) => ( + + {showCols.map((c) => ( + + ))} + + ))} + +
+ {c} +
+ {row[c] ?? '—'} +
+
+ ) +} + +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 ( +
+ +
+ ) + } + + return ( +
+ + Zur Liste + + +

+ + {isNew ? 'Neue CSV-Vorlage' : 'Vorlage bearbeiten'} +

+ + {error && ( +
+ {error} +
+ )} + +
+
Modul
+ + {!isNew && ( +

+ Modul bestehender Vorlagen kann nicht geändert werden. +

+ )} +
+ +
+
1. Beispiel-CSV (wie Import)
+ setFile(e.target.files?.[0] || null)} + /> +
+ + +
+ + {seedHint && ( +

+ Seed: {seedHint.mapping_name} · Übereinstimmung ca.{' '} + {Math.round((seedHint.confidence || 0) * 100)} % +

+ )} + {sampleRows.length > 0 && } +
+ +
+
2. Stammdaten
+ + setMappingName(e.target.value)} + placeholder="z. B. FDDB Export 2026" + /> + +