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")
|
||||
async def analyze_csv(
|
||||
file: UploadFile = File(...),
|
||||
module: str = Form(...),
|
||||
module: Optional[str] = Form(default=None),
|
||||
delimiter: Optional[str] = Form(default=None),
|
||||
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}")
|
||||
|
||||
pid = str(session["profile_id"])
|
||||
raw = await file.read()
|
||||
limits = _load_import_limits()
|
||||
max_bytes = limits.get("max_file_bytes", 52_428_800)
|
||||
|
|
@ -213,20 +216,32 @@ async def analyze_csv(
|
|||
headers, sample_rows, used_delim = parse_csv_sample(text, delimiter=delim, max_data_rows=5)
|
||||
sig = column_signature(headers)
|
||||
|
||||
mod_def = get_module_definition(module)
|
||||
available_fields = mod_def["fields"] if mod_def else {}
|
||||
mod_def = get_module_definition(module) if module else None
|
||||
available_fields = mod_def["fields"] if mod_def else None
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
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 AND module = %s
|
||||
""",
|
||||
(module,),
|
||||
)
|
||||
if module:
|
||||
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 module = %s
|
||||
AND (is_system = true OR (is_system = false AND profile_id = %s::uuid))
|
||||
""",
|
||||
(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()]
|
||||
|
||||
ranked = []
|
||||
|
|
@ -237,27 +252,38 @@ async def analyze_csv(
|
|||
ranked.append(
|
||||
{
|
||||
"mapping_id": t["id"],
|
||||
"module": t["module"],
|
||||
"mapping_name": t["mapping_name"],
|
||||
"is_system": bool(t.get("is_system")),
|
||||
"confidence": round(score, 4),
|
||||
"match_type": "signature_jaccard",
|
||||
}
|
||||
)
|
||||
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 {
|
||||
"module": module,
|
||||
"module_filter": module,
|
||||
"filename": file.filename,
|
||||
"encoding_note": "utf-8/latin-1 mit BOM-Strip",
|
||||
"delimiter": used_delim,
|
||||
"columns": headers,
|
||||
"column_signature_normalized": sig,
|
||||
"sample_rows": sample_rows,
|
||||
"detected_mappings": ranked[:5],
|
||||
"detected_mappings": top,
|
||||
"recommended": recommended,
|
||||
"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(
|
||||
"""
|
||||
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())
|
||||
if not m:
|
||||
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")
|
||||
if not m.get("is_system"):
|
||||
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")
|
||||
async def csv_import_execute(
|
||||
file: UploadFile = File(...),
|
||||
module: str = Form(...),
|
||||
mapping_id: int = Form(...),
|
||||
module: Optional[str] = Form(default=None),
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Universal-CSV-Import mit gespeichertem Mapping (Issue #21).
|
||||
Unterstützt: nutrition, weight, blood_pressure. activity: noch nicht.
|
||||
Universal-CSV-Import: Zielmodul kommt aus der gewählten Vorlage (`mapping_id`).
|
||||
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)
|
||||
|
||||
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')}",
|
||||
)
|
||||
|
||||
_check_module_feature_access(pid, module)
|
||||
|
||||
raw = await file.read()
|
||||
limits = _load_import_limits()
|
||||
max_bytes = limits.get("max_file_bytes", 52_428_800)
|
||||
|
|
@ -349,11 +364,28 @@ async def csv_import_execute(
|
|||
log_id: int | None = None
|
||||
err_response: HTTPException | None = None
|
||||
result: dict | None = None
|
||||
resolved_module: str | None = None
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
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(
|
||||
"""
|
||||
|
|
@ -367,7 +399,7 @@ async def csv_import_execute(
|
|||
'running', NULL, NULL
|
||||
) 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"]
|
||||
|
||||
|
|
@ -376,7 +408,7 @@ async def csv_import_execute(
|
|||
result = run_universal_csv_import(
|
||||
cur,
|
||||
pid,
|
||||
module,
|
||||
exec_module,
|
||||
text,
|
||||
file.filename or "upload.csv",
|
||||
m,
|
||||
|
|
@ -444,16 +476,17 @@ async def csv_import_execute(
|
|||
increment_feature_usage(pid, "data_import")
|
||||
|
||||
ne = result.get("new_entries", result["rows_imported"])
|
||||
if module == "nutrition":
|
||||
if resolved_module == "nutrition":
|
||||
for _ in range(ne):
|
||||
increment_feature_usage(pid, "nutrition_entries")
|
||||
elif module == "weight":
|
||||
elif resolved_module == "weight":
|
||||
for _ in range(ne):
|
||||
increment_feature_usage(pid, "weight_entries")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"import_log_id": log_id,
|
||||
"module": resolved_module,
|
||||
"stats": {
|
||||
"total_rows": result["rows_total"],
|
||||
"imported": result["rows_imported"],
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ MODULE_VERSIONS = {
|
|||
"membership": "2.1.0",
|
||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||
"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)
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +45,7 @@ CHANGELOG = [
|
|||
"API /api/csv: modules, limits, mappings, analyze, copy",
|
||||
"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: GET-Analyse über alle Vorlagen; Import nur mapping_id; GUI ohne Modul-Dropdown",
|
||||
"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 { ArrowLeft, FileSpreadsheet, Loader2 } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/** Module mit funktionierendem Universal-Executor; Aktivität: Legacy-Import auf der Aktivitätsseite. */
|
||||
const UNIVERSAL_IMPORT_IDS = new Set(['nutrition', 'weight', 'blood_pressure'])
|
||||
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
||||
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 }) {
|
||||
if (!sampleRows?.length || !columns?.length) return null
|
||||
|
|
@ -57,124 +101,75 @@ function SampleTable({ sampleRows, columns }) {
|
|||
|
||||
export default function UniversalCsvImportPage() {
|
||||
const navigate = useNavigate()
|
||||
const [modules, setModules] = useState([])
|
||||
const [moduleId, setModuleId] = useState('')
|
||||
const [file, setFile] = useState(null)
|
||||
const [analyzeResult, setAnalyzeResult] = useState(null)
|
||||
const [mappingsList, setMappingsList] = useState({ system_templates: [], user_mappings: [] })
|
||||
const [mappingChoices, setMappingChoices] = useState([])
|
||||
const [mappingId, setMappingId] = useState('')
|
||||
const [loadingModules, setLoadingModules] = useState(true)
|
||||
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
|
||||
const [loadingImport, setLoadingImport] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
|
||||
const loadMappings = useCallback(async (mid) => {
|
||||
if (!mid) {
|
||||
setMappingsList({ system_templates: [], user_mappings: [] })
|
||||
return
|
||||
}
|
||||
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 selectedChoice = useMemo(
|
||||
() => mappingChoices.find((c) => String(c.id) === String(mappingId)),
|
||||
[mappingChoices, mappingId],
|
||||
)
|
||||
const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!file || !moduleId) {
|
||||
setError('Bitte Modul und Datei wählen')
|
||||
if (!file) {
|
||||
setError('Bitte eine CSV-Datei wählen')
|
||||
return
|
||||
}
|
||||
setLoadingAnalyze(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setMappingId('')
|
||||
try {
|
||||
const res = await api.analyzeCsv(file, moduleId)
|
||||
const res = await api.analyzeCsv(file)
|
||||
setAnalyzeResult(res)
|
||||
const mapData = await api.getCsvMappings(moduleId)
|
||||
const sys = mapData.system_templates || []
|
||||
const usr = mapData.user_mappings || []
|
||||
setMappingsList({ system_templates: sys, user_mappings: usr })
|
||||
const options = [
|
||||
...sys.map((m) => ({ id: m.id, name: m.name, isSystem: true })),
|
||||
...usr.map((m) => ({ id: m.id, name: m.name, isSystem: false })),
|
||||
]
|
||||
const detected = res.detected_mappings || []
|
||||
const best = detected[0]
|
||||
let pick = options[0]?.id
|
||||
if (best && (best.confidence || 0) >= 0.5 && options.some((o) => o.id === best.mapping_id)) {
|
||||
pick = best.mapping_id
|
||||
const mapData = await api.getCsvMappings()
|
||||
const merged = mergeMappingChoices(res.detected_mappings || [], mapData)
|
||||
setMappingChoices(merged)
|
||||
|
||||
const best = res.recommended || (res.detected_mappings || [])[0]
|
||||
let pick = ''
|
||||
if (best && merged.some((m) => m.id === best.mapping_id)) {
|
||||
pick = String(best.mapping_id)
|
||||
} else if (merged.length) {
|
||||
const firstReady = merged.find((m) => EXECUTOR_READY.has(m.module))
|
||||
pick = String((firstReady || merged[0]).id)
|
||||
}
|
||||
if (pick != null) setMappingId(String(pick))
|
||||
else setMappingId('')
|
||||
setMappingId(pick)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Analyse fehlgeschlagen')
|
||||
setAnalyzeResult(null)
|
||||
setMappingChoices([])
|
||||
} finally {
|
||||
setLoadingAnalyze(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file || !moduleId || !mappingId) {
|
||||
setError('Bitte Datei, Modul und Vorlage wählen')
|
||||
if (!file || !mappingId) {
|
||||
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
|
||||
}
|
||||
setLoadingImport(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const res = await api.importUniversalCsv(file, moduleId, Number(mappingId))
|
||||
const res = await api.importUniversalCsv(file, Number(mappingId))
|
||||
const st = res.stats || {}
|
||||
const modLabel = MODULE_LABEL[res.module] || res.module || ''
|
||||
setSuccess(
|
||||
`Import abgeschlossen: ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
||||
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`
|
||||
(modLabel ? `${modLabel}: ` : '') +
|
||||
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
||||
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`,
|
||||
)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Import fehlgeschlagen')
|
||||
|
|
@ -204,9 +199,11 @@ export default function UniversalCsvImportPage() {
|
|||
<FileSpreadsheet size={26} strokeWidth={2} />
|
||||
CSV-Import
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.5 }}>
|
||||
Bekannte Formate (FDDB, Apple Health, Omron, …) per Vorlage zuordnen und importieren.
|
||||
Trainingseinheiten weiterhin unter{' '}
|
||||
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
|
||||
Datei hochladen: Die App vergleicht die Spalten-Struktur mit allen gespeicherten Vorlagen und schlägt
|
||||
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
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -247,35 +244,7 @@ export default function UniversalCsvImportPage() {
|
|||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||
<div className="form-label">1. Modul</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>
|
||||
<div className="form-label">1. CSV-Datei</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
|
|
@ -284,6 +253,8 @@ export default function UniversalCsvImportPage() {
|
|||
onChange={(e) => {
|
||||
setFile(e.target.files?.[0] || null)
|
||||
setAnalyzeResult(null)
|
||||
setMappingChoices([])
|
||||
setMappingId('')
|
||||
setSuccess(null)
|
||||
}}
|
||||
/>
|
||||
|
|
@ -291,37 +262,59 @@ export default function UniversalCsvImportPage() {
|
|||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 12, width: '100%' }}
|
||||
disabled={!file || !moduleId || loadingAnalyze}
|
||||
disabled={!file || loadingAnalyze}
|
||||
onClick={handleAnalyze}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{analyzeResult && (
|
||||
<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 }}>
|
||||
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten: {analyzeResult.columns?.length ?? 0}
|
||||
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten:{' '}
|
||||
{analyzeResult.columns?.length ?? 0}
|
||||
</div>
|
||||
{(analyzeResult.detected_mappings || []).length > 0 && (
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>
|
||||
<strong>Erkannte Vorlagen:</strong>
|
||||
<ul style={{ margin: '6px 0 0 18px', padding: 0 }}>
|
||||
{analyzeResult.detected_mappings.map((d) => (
|
||||
|
||||
{analyzeResult.recommended && (
|
||||
<div
|
||||
style={{
|
||||
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}>
|
||||
{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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<SampleTable sampleRows={analyzeResult.sample_rows} columns={analyzeResult.columns} />
|
||||
|
||||
<div className="form-label" style={{ marginTop: 16 }}>
|
||||
|
|
@ -333,27 +326,38 @@ export default function UniversalCsvImportPage() {
|
|||
onChange={(e) => setMappingId(e.target.value)}
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
>
|
||||
{allMappingOptions.length === 0 ? (
|
||||
<option value="">Keine Vorlage für dieses Modul</option>
|
||||
{mappingChoices.length === 0 ? (
|
||||
<option value="">Keine Vorlage geladen</option>
|
||||
) : (
|
||||
allMappingOptions.map((o) => (
|
||||
mappingChoices.map((o) => (
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 16, width: '100%' }}
|
||||
disabled={!file || !mappingId || loadingImport}
|
||||
disabled={!file || !mappingId || !importAllowed || loadingImport}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{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'
|
||||
|
|
|
|||
|
|
@ -489,11 +489,10 @@ export const api = {
|
|||
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
||||
copyCsvMapping: (mappingId, body = null) =>
|
||||
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
||||
/** Universal-CSV (Issue #21): file + module + mapping_id aus /csv/mappings */
|
||||
importUniversalCsv: async (file, module, mappingId) => {
|
||||
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
|
||||
importUniversalCsv: async (file, mappingId) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('module', module)
|
||||
fd.append('mapping_id', String(mappingId))
|
||||
const res = await fetch(BASE + '/csv/import', { method: 'POST', headers: hdrs(), body: fd })
|
||||
if (!res.ok) {
|
||||
|
|
@ -506,10 +505,11 @@ export const api = {
|
|||
}
|
||||
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()
|
||||
fd.append('file', file)
|
||||
fd.append('module', module)
|
||||
if (module) fd.append('module', module)
|
||||
if (delimiter) fd.append('delimiter', delimiter)
|
||||
const res = await fetch(BASE + '/csv/analyze', { method: 'POST', headers: hdrs(), body: fd })
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user