feat(csv-import): Update CSV import functionality and enhance analysis features
- Bumped version of csv_import to 0.3.0, reflecting new analysis capabilities. - Modified analyze_csv endpoint to allow optional module filtering, improving flexibility in template selection. - Enhanced the import process to support both system and user-defined templates, ensuring backward compatibility. - Updated frontend to streamline mapping choices and improve user experience during CSV analysis and import. - Added detailed error handling and user feedback for import operations.
This commit is contained in:
parent
7e9da46fe5
commit
5e5f3b4e5a
|
|
@ -183,16 +183,19 @@ def copy_csv_mapping(
|
||||||
@router.post("/analyze")
|
@router.post("/analyze")
|
||||||
async def analyze_csv(
|
async def analyze_csv(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
module: str = Form(...),
|
module: Optional[str] = Form(default=None),
|
||||||
delimiter: Optional[str] = Form(default=None),
|
delimiter: Optional[str] = Form(default=None),
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Erste Zeilen parsen, Signatur bilden, System-Templates nach Ähnlichkeit ranken.
|
Erste Zeilen parsen, Signatur bilden, Vorlagen nach Ähnlichkeit ranken.
|
||||||
|
Ohne `module`: alle System- + eigene User-Vorlagen (echte Format-Erkennung über Spalten-Signatur).
|
||||||
|
Mit `module`: nur Vorlagen dieses Moduls (Abwärtskompatibilität).
|
||||||
"""
|
"""
|
||||||
if not get_module_definition(module):
|
if module and not get_module_definition(module):
|
||||||
raise HTTPException(400, f"Unbekanntes Modul: {module}")
|
raise HTTPException(400, f"Unbekanntes Modul: {module}")
|
||||||
|
|
||||||
|
pid = str(session["profile_id"])
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
limits = _load_import_limits()
|
limits = _load_import_limits()
|
||||||
max_bytes = limits.get("max_file_bytes", 52_428_800)
|
max_bytes = limits.get("max_file_bytes", 52_428_800)
|
||||||
|
|
@ -213,19 +216,31 @@ async def analyze_csv(
|
||||||
headers, sample_rows, used_delim = parse_csv_sample(text, delimiter=delim, max_data_rows=5)
|
headers, sample_rows, used_delim = parse_csv_sample(text, delimiter=delim, max_data_rows=5)
|
||||||
sig = column_signature(headers)
|
sig = column_signature(headers)
|
||||||
|
|
||||||
mod_def = get_module_definition(module)
|
mod_def = get_module_definition(module) if module else None
|
||||||
available_fields = mod_def["fields"] if mod_def else {}
|
available_fields = mod_def["fields"] if mod_def else None
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
if module:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, module, mapping_name, description, column_signature,
|
SELECT id, module, mapping_name, description, column_signature,
|
||||||
delimiter, encoding, has_header, field_mappings, type_conversions, is_system
|
delimiter, encoding, has_header, field_mappings, type_conversions, is_system
|
||||||
FROM csv_field_mappings
|
FROM csv_field_mappings
|
||||||
WHERE is_system = true AND module = %s
|
WHERE module = %s
|
||||||
|
AND (is_system = true OR (is_system = false AND profile_id = %s::uuid))
|
||||||
""",
|
""",
|
||||||
(module,),
|
(module, pid),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, module, mapping_name, description, column_signature,
|
||||||
|
delimiter, encoding, has_header, field_mappings, type_conversions, is_system
|
||||||
|
FROM csv_field_mappings
|
||||||
|
WHERE is_system = true OR (is_system = false AND profile_id = %s::uuid)
|
||||||
|
""",
|
||||||
|
(pid,),
|
||||||
)
|
)
|
||||||
templates = [r2d(r) for r in cur.fetchall()]
|
templates = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
@ -237,27 +252,38 @@ async def analyze_csv(
|
||||||
ranked.append(
|
ranked.append(
|
||||||
{
|
{
|
||||||
"mapping_id": t["id"],
|
"mapping_id": t["id"],
|
||||||
|
"module": t["module"],
|
||||||
"mapping_name": t["mapping_name"],
|
"mapping_name": t["mapping_name"],
|
||||||
|
"is_system": bool(t.get("is_system")),
|
||||||
"confidence": round(score, 4),
|
"confidence": round(score, 4),
|
||||||
"match_type": "signature_jaccard",
|
"match_type": "signature_jaccard",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ranked.sort(key=lambda x: -x["confidence"])
|
ranked.sort(key=lambda x: -x["confidence"])
|
||||||
|
|
||||||
|
top = ranked[:25]
|
||||||
|
recommended = top[0] if top and (top[0]["confidence"] or 0) > 0 else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"module": module,
|
"module_filter": module,
|
||||||
"filename": file.filename,
|
"filename": file.filename,
|
||||||
"encoding_note": "utf-8/latin-1 mit BOM-Strip",
|
"encoding_note": "utf-8/latin-1 mit BOM-Strip",
|
||||||
"delimiter": used_delim,
|
"delimiter": used_delim,
|
||||||
"columns": headers,
|
"columns": headers,
|
||||||
"column_signature_normalized": sig,
|
"column_signature_normalized": sig,
|
||||||
"sample_rows": sample_rows,
|
"sample_rows": sample_rows,
|
||||||
"detected_mappings": ranked[:5],
|
"detected_mappings": top,
|
||||||
|
"recommended": recommended,
|
||||||
"available_fields": available_fields,
|
"available_fields": available_fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fetch_mapping_row(cur, mapping_id: int, profile_id: str, module: str) -> dict:
|
def _fetch_mapping_row(
|
||||||
|
cur,
|
||||||
|
mapping_id: int,
|
||||||
|
profile_id: str,
|
||||||
|
module: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM csv_field_mappings WHERE id = %s
|
SELECT * FROM csv_field_mappings WHERE id = %s
|
||||||
|
|
@ -267,7 +293,7 @@ def _fetch_mapping_row(cur, mapping_id: int, profile_id: str, module: str) -> di
|
||||||
m = r2d(cur.fetchone())
|
m = r2d(cur.fetchone())
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "Mapping nicht gefunden")
|
raise HTTPException(404, "Mapping nicht gefunden")
|
||||||
if m.get("module") != module:
|
if module is not None and m.get("module") != module:
|
||||||
raise HTTPException(400, "Mapping gehört zu einem anderen Modul")
|
raise HTTPException(400, "Mapping gehört zu einem anderen Modul")
|
||||||
if not m.get("is_system"):
|
if not m.get("is_system"):
|
||||||
if str(m.get("profile_id") or "") != profile_id:
|
if str(m.get("profile_id") or "") != profile_id:
|
||||||
|
|
@ -297,24 +323,15 @@ def _check_module_feature_access(pid: str, module: str) -> None:
|
||||||
@router.post("/import")
|
@router.post("/import")
|
||||||
async def csv_import_execute(
|
async def csv_import_execute(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
module: str = Form(...),
|
|
||||||
mapping_id: int = Form(...),
|
mapping_id: int = Form(...),
|
||||||
|
module: Optional[str] = Form(default=None),
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Universal-CSV-Import mit gespeichertem Mapping (Issue #21).
|
Universal-CSV-Import: Zielmodul kommt aus der gewählten Vorlage (`mapping_id`).
|
||||||
Unterstützt: nutrition, weight, blood_pressure. activity: noch nicht.
|
Optional `module` dient nur der Absicherung (muss mit Vorlage übereinstimmen).
|
||||||
"""
|
"""
|
||||||
if module == "activity":
|
|
||||||
raise HTTPException(
|
|
||||||
501,
|
|
||||||
"Aktivitäts-CSV über den Universal-Importer ist noch nicht freigeschaltet "
|
|
||||||
"(Training-Type-Mapping). Bitte weiterhin /api/activity/import nutzen.",
|
|
||||||
)
|
|
||||||
if not get_module_definition(module):
|
|
||||||
raise HTTPException(400, f"Unbekanntes oder nicht unterstütztes Modul: {module}")
|
|
||||||
|
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
access_di = check_feature_access(pid, "data_import")
|
access_di = check_feature_access(pid, "data_import")
|
||||||
|
|
@ -326,8 +343,6 @@ async def csv_import_execute(
|
||||||
f"{access_di.get('used')}/{access_di.get('limit')}",
|
f"{access_di.get('used')}/{access_di.get('limit')}",
|
||||||
)
|
)
|
||||||
|
|
||||||
_check_module_feature_access(pid, module)
|
|
||||||
|
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
limits = _load_import_limits()
|
limits = _load_import_limits()
|
||||||
max_bytes = limits.get("max_file_bytes", 52_428_800)
|
max_bytes = limits.get("max_file_bytes", 52_428_800)
|
||||||
|
|
@ -349,11 +364,28 @@ async def csv_import_execute(
|
||||||
log_id: int | None = None
|
log_id: int | None = None
|
||||||
err_response: HTTPException | None = None
|
err_response: HTTPException | None = None
|
||||||
result: dict | None = None
|
result: dict | None = None
|
||||||
|
resolved_module: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
m = _fetch_mapping_row(cur, mapping_id, pid, module)
|
m = _fetch_mapping_row(cur, mapping_id, pid, module)
|
||||||
|
exec_module = m["module"]
|
||||||
|
resolved_module = exec_module
|
||||||
|
|
||||||
|
if exec_module == "activity":
|
||||||
|
raise HTTPException(
|
||||||
|
501,
|
||||||
|
"Aktivitäts-CSV über den Universal-Importer ist noch nicht freigeschaltet "
|
||||||
|
"(Training-Type-Mapping). Bitte weiterhin /api/activity/import nutzen.",
|
||||||
|
)
|
||||||
|
if not get_module_definition(exec_module):
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Modul der Vorlage wird vom Importer noch nicht unterstützt: {exec_module}",
|
||||||
|
)
|
||||||
|
|
||||||
|
_check_module_feature_access(pid, exec_module)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -367,7 +399,7 @@ async def csv_import_execute(
|
||||||
'running', NULL, NULL
|
'running', NULL, NULL
|
||||||
) RETURNING id
|
) RETURNING id
|
||||||
""",
|
""",
|
||||||
(pid, mapping_id, module, file.filename or "upload.csv"),
|
(pid, mapping_id, exec_module, file.filename or "upload.csv"),
|
||||||
)
|
)
|
||||||
log_id = cur.fetchone()["id"]
|
log_id = cur.fetchone()["id"]
|
||||||
|
|
||||||
|
|
@ -376,7 +408,7 @@ async def csv_import_execute(
|
||||||
result = run_universal_csv_import(
|
result = run_universal_csv_import(
|
||||||
cur,
|
cur,
|
||||||
pid,
|
pid,
|
||||||
module,
|
exec_module,
|
||||||
text,
|
text,
|
||||||
file.filename or "upload.csv",
|
file.filename or "upload.csv",
|
||||||
m,
|
m,
|
||||||
|
|
@ -444,16 +476,17 @@ async def csv_import_execute(
|
||||||
increment_feature_usage(pid, "data_import")
|
increment_feature_usage(pid, "data_import")
|
||||||
|
|
||||||
ne = result.get("new_entries", result["rows_imported"])
|
ne = result.get("new_entries", result["rows_imported"])
|
||||||
if module == "nutrition":
|
if resolved_module == "nutrition":
|
||||||
for _ in range(ne):
|
for _ in range(ne):
|
||||||
increment_feature_usage(pid, "nutrition_entries")
|
increment_feature_usage(pid, "nutrition_entries")
|
||||||
elif module == "weight":
|
elif resolved_module == "weight":
|
||||||
for _ in range(ne):
|
for _ in range(ne):
|
||||||
increment_feature_usage(pid, "weight_entries")
|
increment_feature_usage(pid, "weight_entries")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"import_log_id": log_id,
|
"import_log_id": log_id,
|
||||||
|
"module": resolved_module,
|
||||||
"stats": {
|
"stats": {
|
||||||
"total_rows": result["rows_total"],
|
"total_rows": result["rows_total"],
|
||||||
"imported": result["rows_imported"],
|
"imported": result["rows_imported"],
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ MODULE_VERSIONS = {
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||||
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
|
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
|
||||||
"csv_import": "0.2.0", # Issue #21: + POST /csv/import (nutrition, weight, blood_pressure)
|
"csv_import": "0.3.0", # Analyse ohne Modul-Filter; Import nur mapping_id (Modul aus Vorlage)
|
||||||
"admin_csv_templates": "0.1.0", # Issue #21: System-Templates + Import-Limits (Admin)
|
"admin_csv_templates": "0.1.0", # Issue #21: System-Templates + Import-Limits (Admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +45,7 @@ CHANGELOG = [
|
||||||
"API /api/csv: modules, limits, mappings, analyze, copy",
|
"API /api/csv: modules, limits, mappings, analyze, copy",
|
||||||
"API /api/admin/csv-templates: CRUD System-Templates, import-limits (system_config)",
|
"API /api/admin/csv-templates: CRUD System-Templates, import-limits (system_config)",
|
||||||
"Issue #21: POST /api/csv/import + executor (nutrition Aggregat/Tag, weight, Blutdruck); activity 501",
|
"Issue #21: POST /api/csv/import + executor (nutrition Aggregat/Tag, weight, Blutdruck); activity 501",
|
||||||
|
"Issue #21: GET-Analyse über alle Vorlagen; Import nur mapping_id; GUI ohne Modul-Dropdown",
|
||||||
"v9c_cleanup_features.sql: FK-sichere csv_import→data_import Reihenfolge",
|
"v9c_cleanup_features.sql: FK-sichere csv_import→data_import Reihenfolge",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,54 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, FileSpreadsheet, Loader2 } from 'lucide-react'
|
import { ArrowLeft, FileSpreadsheet, Loader2 } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
/** Module mit funktionierendem Universal-Executor; Aktivität: Legacy-Import auf der Aktivitätsseite. */
|
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
||||||
const UNIVERSAL_IMPORT_IDS = new Set(['nutrition', 'weight', 'blood_pressure'])
|
const EXECUTOR_READY = new Set(['nutrition', 'weight', 'blood_pressure'])
|
||||||
|
|
||||||
|
const MODULE_LABEL = {
|
||||||
|
nutrition: 'Ernährung',
|
||||||
|
weight: 'Gewicht',
|
||||||
|
blood_pressure: 'Blutdruck',
|
||||||
|
activity: 'Aktivität',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMappingChoices(detected, mapData) {
|
||||||
|
const sys = mapData.system_templates || []
|
||||||
|
const usr = mapData.user_mappings || []
|
||||||
|
const flat = [
|
||||||
|
...sys.map((m) => ({ ...m, _isSys: true })),
|
||||||
|
...usr.map((m) => ({ ...m, _isSys: false })),
|
||||||
|
]
|
||||||
|
const byId = new Map(flat.map((m) => [m.id, m]))
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const d of detected || []) {
|
||||||
|
const row = byId.get(d.mapping_id)
|
||||||
|
if (row) {
|
||||||
|
out.push({
|
||||||
|
id: row.id,
|
||||||
|
module: row.module,
|
||||||
|
name: row.name,
|
||||||
|
is_system: row.is_system,
|
||||||
|
confidence: d.confidence ?? 0,
|
||||||
|
})
|
||||||
|
seen.add(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of flat) {
|
||||||
|
if (!seen.has(row.id)) {
|
||||||
|
out.push({
|
||||||
|
id: row.id,
|
||||||
|
module: row.module,
|
||||||
|
name: row.name,
|
||||||
|
is_system: row.is_system,
|
||||||
|
confidence: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function SampleTable({ sampleRows, columns }) {
|
function SampleTable({ sampleRows, columns }) {
|
||||||
if (!sampleRows?.length || !columns?.length) return null
|
if (!sampleRows?.length || !columns?.length) return null
|
||||||
|
|
@ -57,124 +101,75 @@ function SampleTable({ sampleRows, columns }) {
|
||||||
|
|
||||||
export default function UniversalCsvImportPage() {
|
export default function UniversalCsvImportPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [modules, setModules] = useState([])
|
|
||||||
const [moduleId, setModuleId] = useState('')
|
|
||||||
const [file, setFile] = useState(null)
|
const [file, setFile] = useState(null)
|
||||||
const [analyzeResult, setAnalyzeResult] = useState(null)
|
const [analyzeResult, setAnalyzeResult] = useState(null)
|
||||||
const [mappingsList, setMappingsList] = useState({ system_templates: [], user_mappings: [] })
|
const [mappingChoices, setMappingChoices] = useState([])
|
||||||
const [mappingId, setMappingId] = useState('')
|
const [mappingId, setMappingId] = useState('')
|
||||||
const [loadingModules, setLoadingModules] = useState(true)
|
|
||||||
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
|
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
|
||||||
const [loadingImport, setLoadingImport] = useState(false)
|
const [loadingImport, setLoadingImport] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [success, setSuccess] = useState(null)
|
const [success, setSuccess] = useState(null)
|
||||||
|
|
||||||
const loadMappings = useCallback(async (mid) => {
|
const selectedChoice = useMemo(
|
||||||
if (!mid) {
|
() => mappingChoices.find((c) => String(c.id) === String(mappingId)),
|
||||||
setMappingsList({ system_templates: [], user_mappings: [] })
|
[mappingChoices, mappingId],
|
||||||
return
|
)
|
||||||
}
|
const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
|
||||||
const data = await api.getCsvMappings(mid)
|
|
||||||
setMappingsList({
|
|
||||||
system_templates: data.system_templates || [],
|
|
||||||
user_mappings: data.user_mappings || [],
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancel = false
|
|
||||||
;(async () => {
|
|
||||||
setLoadingModules(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const data = await api.getCsvModules()
|
|
||||||
const list = (data.modules || []).filter((m) => UNIVERSAL_IMPORT_IDS.has(m.id))
|
|
||||||
if (!cancel) {
|
|
||||||
setModules(list)
|
|
||||||
if (list.length && !moduleId) setModuleId(list[0].id)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!cancel) setError(e.message || 'Module konnten nicht geladen werden')
|
|
||||||
} finally {
|
|
||||||
if (!cancel) setLoadingModules(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => {
|
|
||||||
cancel = true
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (moduleId) {
|
|
||||||
loadMappings(moduleId)
|
|
||||||
setAnalyzeResult(null)
|
|
||||||
setMappingId('')
|
|
||||||
setSuccess(null)
|
|
||||||
}
|
|
||||||
}, [moduleId, loadMappings])
|
|
||||||
|
|
||||||
const allMappingOptions = [
|
|
||||||
...(mappingsList.system_templates || []).map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
label: `${m.name} (System)`,
|
|
||||||
isSystem: true,
|
|
||||||
})),
|
|
||||||
...(mappingsList.user_mappings || []).map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
label: m.name,
|
|
||||||
isSystem: false,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
if (!file || !moduleId) {
|
if (!file) {
|
||||||
setError('Bitte Modul und Datei wählen')
|
setError('Bitte eine CSV-Datei wählen')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoadingAnalyze(true)
|
setLoadingAnalyze(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
|
setMappingId('')
|
||||||
try {
|
try {
|
||||||
const res = await api.analyzeCsv(file, moduleId)
|
const res = await api.analyzeCsv(file)
|
||||||
setAnalyzeResult(res)
|
setAnalyzeResult(res)
|
||||||
const mapData = await api.getCsvMappings(moduleId)
|
const mapData = await api.getCsvMappings()
|
||||||
const sys = mapData.system_templates || []
|
const merged = mergeMappingChoices(res.detected_mappings || [], mapData)
|
||||||
const usr = mapData.user_mappings || []
|
setMappingChoices(merged)
|
||||||
setMappingsList({ system_templates: sys, user_mappings: usr })
|
|
||||||
const options = [
|
const best = res.recommended || (res.detected_mappings || [])[0]
|
||||||
...sys.map((m) => ({ id: m.id, name: m.name, isSystem: true })),
|
let pick = ''
|
||||||
...usr.map((m) => ({ id: m.id, name: m.name, isSystem: false })),
|
if (best && merged.some((m) => m.id === best.mapping_id)) {
|
||||||
]
|
pick = String(best.mapping_id)
|
||||||
const detected = res.detected_mappings || []
|
} else if (merged.length) {
|
||||||
const best = detected[0]
|
const firstReady = merged.find((m) => EXECUTOR_READY.has(m.module))
|
||||||
let pick = options[0]?.id
|
pick = String((firstReady || merged[0]).id)
|
||||||
if (best && (best.confidence || 0) >= 0.5 && options.some((o) => o.id === best.mapping_id)) {
|
|
||||||
pick = best.mapping_id
|
|
||||||
}
|
}
|
||||||
if (pick != null) setMappingId(String(pick))
|
setMappingId(pick)
|
||||||
else setMappingId('')
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Analyse fehlgeschlagen')
|
setError(e.message || 'Analyse fehlgeschlagen')
|
||||||
setAnalyzeResult(null)
|
setAnalyzeResult(null)
|
||||||
|
setMappingChoices([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAnalyze(false)
|
setLoadingAnalyze(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!file || !moduleId || !mappingId) {
|
if (!file || !mappingId) {
|
||||||
setError('Bitte Datei, Modul und Vorlage wählen')
|
setError('Bitte Datei und Vorlage wählen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!importAllowed) {
|
||||||
|
setError('Diese Vorlage kann hier noch nicht importiert werden (z. B. Aktivität → Seite „Aktivität“).')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoadingImport(true)
|
setLoadingImport(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
try {
|
try {
|
||||||
const res = await api.importUniversalCsv(file, moduleId, Number(mappingId))
|
const res = await api.importUniversalCsv(file, Number(mappingId))
|
||||||
const st = res.stats || {}
|
const st = res.stats || {}
|
||||||
|
const modLabel = MODULE_LABEL[res.module] || res.module || ''
|
||||||
setSuccess(
|
setSuccess(
|
||||||
`Import abgeschlossen: ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
(modLabel ? `${modLabel}: ` : '') +
|
||||||
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`
|
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
||||||
|
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`,
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Import fehlgeschlagen')
|
setError(e.message || 'Import fehlgeschlagen')
|
||||||
|
|
@ -204,9 +199,11 @@ export default function UniversalCsvImportPage() {
|
||||||
<FileSpreadsheet size={26} strokeWidth={2} />
|
<FileSpreadsheet size={26} strokeWidth={2} />
|
||||||
CSV-Import
|
CSV-Import
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.5 }}>
|
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
|
||||||
Bekannte Formate (FDDB, Apple Health, Omron, …) per Vorlage zuordnen und importieren.
|
Datei hochladen: Die App vergleicht die Spalten-Struktur mit allen gespeicherten Vorlagen und schlägt
|
||||||
Trainingseinheiten weiterhin unter{' '}
|
passende Ziele vor (Ernährung, Gewicht, Blutdruck, …). Du bestätigst nur noch die Vorlage — ohne
|
||||||
|
vorher ein „Modul“ raten zu müssen.{' '}
|
||||||
|
<strong>Später:</strong> eine Datei, mehrere Zieltabellen. Trainingseinheiten aktuell weiter unter{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
@ -247,35 +244,7 @@ export default function UniversalCsvImportPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||||
<div className="form-label">1. Modul</div>
|
<div className="form-label">1. CSV-Datei</div>
|
||||||
{loadingModules ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text3)' }}>
|
|
||||||
<Loader2 size={18} style={{ animation: 'spin 0.7s linear infinite' }} /> Lade …
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={moduleId}
|
|
||||||
onChange={(e) => setModuleId(e.target.value)}
|
|
||||||
style={{ width: '100%', marginTop: 8 }}
|
|
||||||
>
|
|
||||||
{modules.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.id === 'nutrition'
|
|
||||||
? 'Ernährung'
|
|
||||||
: m.id === 'weight'
|
|
||||||
? 'Gewicht'
|
|
||||||
: m.id === 'blood_pressure'
|
|
||||||
? 'Blutdruck'
|
|
||||||
: m.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
|
||||||
<div className="form-label">2. CSV-Datei</div>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,text/csv"
|
accept=".csv,text/csv"
|
||||||
|
|
@ -284,6 +253,8 @@ export default function UniversalCsvImportPage() {
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFile(e.target.files?.[0] || null)
|
setFile(e.target.files?.[0] || null)
|
||||||
setAnalyzeResult(null)
|
setAnalyzeResult(null)
|
||||||
|
setMappingChoices([])
|
||||||
|
setMappingId('')
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -291,37 +262,59 @@ export default function UniversalCsvImportPage() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ marginTop: 12, width: '100%' }}
|
style={{ marginTop: 12, width: '100%' }}
|
||||||
disabled={!file || !moduleId || loadingAnalyze}
|
disabled={!file || loadingAnalyze}
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
>
|
>
|
||||||
{loadingAnalyze ? (
|
{loadingAnalyze ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Analysiere …
|
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Datei
|
||||||
|
wird erkannt …
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Vorschau und Erkennung'
|
'Datei analysieren'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{analyzeResult && (
|
{analyzeResult && (
|
||||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||||
<div className="form-label">3. Vorschau</div>
|
<div className="form-label">2. Erkennung & Vorschau</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
|
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
|
||||||
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten: {analyzeResult.columns?.length ?? 0}
|
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten:{' '}
|
||||||
|
{analyzeResult.columns?.length ?? 0}
|
||||||
</div>
|
</div>
|
||||||
{(analyzeResult.detected_mappings || []).length > 0 && (
|
|
||||||
<div style={{ marginTop: 10, fontSize: 13 }}>
|
{analyzeResult.recommended && (
|
||||||
<strong>Erkannte Vorlagen:</strong>
|
<div
|
||||||
<ul style={{ margin: '6px 0 0 18px', padding: 0 }}>
|
style={{
|
||||||
{analyzeResult.detected_mappings.map((d) => (
|
marginTop: 12,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Vorschlag:</strong>{' '}
|
||||||
|
{MODULE_LABEL[analyzeResult.recommended.module] || analyzeResult.recommended.module} —{' '}
|
||||||
|
{analyzeResult.recommended.mapping_name} (
|
||||||
|
{Math.round((analyzeResult.recommended.confidence || 0) * 100)} % Übereinstimmung der Spalten)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(analyzeResult.detected_mappings || []).length > 1 && (
|
||||||
|
<details style={{ marginTop: 12, fontSize: 13 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: 'var(--text2)' }}>Weitere Treffer</summary>
|
||||||
|
<ul style={{ margin: '8px 0 0 18px', padding: 0 }}>
|
||||||
|
{analyzeResult.detected_mappings.slice(1, 8).map((d) => (
|
||||||
<li key={d.mapping_id}>
|
<li key={d.mapping_id}>
|
||||||
{d.mapping_name} · Übereinstimmung ca. {Math.round((d.confidence || 0) * 100)} %
|
{MODULE_LABEL[d.module] || d.module}: {d.mapping_name} ·{' '}
|
||||||
|
{Math.round((d.confidence || 0) * 100)} %
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SampleTable sampleRows={analyzeResult.sample_rows} columns={analyzeResult.columns} />
|
<SampleTable sampleRows={analyzeResult.sample_rows} columns={analyzeResult.columns} />
|
||||||
|
|
||||||
<div className="form-label" style={{ marginTop: 16 }}>
|
<div className="form-label" style={{ marginTop: 16 }}>
|
||||||
|
|
@ -333,27 +326,38 @@ export default function UniversalCsvImportPage() {
|
||||||
onChange={(e) => setMappingId(e.target.value)}
|
onChange={(e) => setMappingId(e.target.value)}
|
||||||
style={{ width: '100%', marginTop: 8 }}
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
>
|
>
|
||||||
{allMappingOptions.length === 0 ? (
|
{mappingChoices.length === 0 ? (
|
||||||
<option value="">Keine Vorlage für dieses Modul</option>
|
<option value="">Keine Vorlage geladen</option>
|
||||||
) : (
|
) : (
|
||||||
allMappingOptions.map((o) => (
|
mappingChoices.map((o) => (
|
||||||
<option key={o.id} value={o.id}>
|
<option key={o.id} value={o.id}>
|
||||||
{o.label}
|
{MODULE_LABEL[o.module] || o.module} — {o.name}
|
||||||
|
{o.is_system ? ' (System)' : ''}
|
||||||
|
{o.confidence > 0 ? ` · ${Math.round(o.confidence * 100)} %` : ''}
|
||||||
|
{!EXECUTOR_READY.has(o.module) ? ' · Import: noch nicht hier' : ''}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{selectedChoice && !importAllowed && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 10 }}>
|
||||||
|
Diese Vorlage passt strukturell, wird aber an der passenden Stelle importiert (z. B. Aktivität →
|
||||||
|
Apple-Health-CSV dort).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ marginTop: 16, width: '100%' }}
|
style={{ marginTop: 16, width: '100%' }}
|
||||||
disabled={!file || !mappingId || loadingImport}
|
disabled={!file || !mappingId || !importAllowed || loadingImport}
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
>
|
>
|
||||||
{loadingImport ? (
|
{loadingImport ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import läuft …
|
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import
|
||||||
|
läuft …
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Import starten'
|
'Import starten'
|
||||||
|
|
|
||||||
|
|
@ -489,11 +489,10 @@ export const api = {
|
||||||
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
||||||
copyCsvMapping: (mappingId, body = null) =>
|
copyCsvMapping: (mappingId, body = null) =>
|
||||||
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
||||||
/** Universal-CSV (Issue #21): file + module + mapping_id aus /csv/mappings */
|
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
|
||||||
importUniversalCsv: async (file, module, mappingId) => {
|
importUniversalCsv: async (file, mappingId) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
fd.append('module', module)
|
|
||||||
fd.append('mapping_id', String(mappingId))
|
fd.append('mapping_id', String(mappingId))
|
||||||
const res = await fetch(BASE + '/csv/import', { method: 'POST', headers: hdrs(), body: fd })
|
const res = await fetch(BASE + '/csv/import', { method: 'POST', headers: hdrs(), body: fd })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -506,10 +505,11 @@ export const api = {
|
||||||
}
|
}
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
analyzeCsv: async (file, module, delimiter = null) => {
|
/** optional module = nur diese Vorlagen-Gruppe (Abwärtskompatibilität); ohne = globale Erkennung */
|
||||||
|
analyzeCsv: async (file, module = null, delimiter = null) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
fd.append('module', module)
|
if (module) fd.append('module', module)
|
||||||
if (delimiter) fd.append('delimiter', delimiter)
|
if (delimiter) fd.append('delimiter', delimiter)
|
||||||
const res = await fetch(BASE + '/csv/analyze', { method: 'POST', headers: hdrs(), body: fd })
|
const res = await fetch(BASE + '/csv/analyze', { method: 'POST', headers: hdrs(), body: fd })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user