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.
This commit is contained in:
parent
338163ac0b
commit
c10da55ec6
179
backend/csv_parser/mapping_suggest.py
Normal file
179
backend/csv_parser/mapping_suggest.py
Normal 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
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
42
backend/tests/test_mapping_suggest.py
Normal file
42
backend/tests/test_mapping_suggest.py
Normal 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
|
||||
|
|
@ -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/>}/>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
503
frontend/src/pages/AdminCsvTemplateEditorPage.jsx
Normal file
503
frontend/src/pages/AdminCsvTemplateEditorPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
frontend/src/pages/AdminCsvTemplatesPage.jsx
Normal file
121
frontend/src/pages/AdminCsvTemplatesPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user