mitai-jinkendo/frontend/src/pages/UniversalCsvImportPage.jsx
Lars 5b96bd4f75
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat(csv-import): Add blood pressure and activity row diagnosis functionality
- Introduced `diagnose_blood_pressure_row` and `diagnose_activity_row` functions to validate and analyze blood pressure and activity data from CSV imports.
- Updated the CSV import logic to handle combined datetime columns for blood pressure and activity, improving data integrity during import.
- Enhanced type conversion specifications to include `start_time` for blood pressure and activity, ensuring accurate data mapping.
- Added tests to validate the new diagnosis functions and their integration with existing import processes, ensuring robustness and reliability.
- Updated frontend messages to provide clearer guidance on blood pressure and activity data handling during CSV imports.
2026-04-10 16:43:00 +02:00

585 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
import { api } from '../utils/api'
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
const EXECUTOR_READY = new Set([
'nutrition',
'weight',
'blood_pressure',
'activity',
'sleep',
'vitals_baseline',
])
const MODULE_LABEL = {
nutrition: 'Ernährung',
weight: 'Gewicht',
blood_pressure: 'Blutdruck',
activity: 'Aktivität',
sleep: 'Schlaf',
vitals_baseline: 'Vitalwerte (Baseline)',
}
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,
jaccard: d.jaccard,
template_recall: d.template_recall,
columns_matched: d.columns_matched,
columns_in_template: d.columns_in_template,
columns_in_csv: d.columns_in_csv,
})
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
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={csvPreviewTdStyle(row[c] ?? '—')}>
{row[c] ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function UniversalCsvImportPage() {
const navigate = useNavigate()
const fileInputRef = useRef(null)
const analyzeGenRef = useRef(0)
const [file, setFile] = useState(null)
const [dragActive, setDragActive] = useState(false)
const [analyzeResult, setAnalyzeResult] = useState(null)
const [mappingChoices, setMappingChoices] = useState([])
const [mappingId, setMappingId] = useState('')
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
const [loadingImport, setLoadingImport] = useState(false)
const [loadingDiagnose, setLoadingDiagnose] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const [lastImport, setLastImport] = useState(null)
const [diagnoseResult, setDiagnoseResult] = useState(null)
const selectedChoice = useMemo(
() => mappingChoices.find((c) => String(c.id) === String(mappingId)),
[mappingChoices, mappingId],
)
const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
const runAnalyze = async (fileToAnalyze) => {
if (!fileToAnalyze) return
const gen = ++analyzeGenRef.current
setLoadingAnalyze(true)
setError(null)
setSuccess(null)
setMappingId('')
try {
const res = await api.analyzeCsv(fileToAnalyze)
if (gen !== analyzeGenRef.current) return
setAnalyzeResult(res)
const mapData = await api.getCsvMappings()
if (gen !== analyzeGenRef.current) return
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)
}
setMappingId(pick)
} catch (e) {
if (gen !== analyzeGenRef.current) return
setError(e.message || 'Analyse fehlgeschlagen')
setAnalyzeResult(null)
setMappingChoices([])
} finally {
if (gen === analyzeGenRef.current) {
setLoadingAnalyze(false)
}
}
}
const assignCsvFile = (f) => {
if (!f) return
const name = (f.name || '').toLowerCase()
if (!name.endsWith('.csv')) {
setError('Bitte eine .csv-Datei wählen.')
return
}
setFile(f)
setAnalyzeResult(null)
setMappingChoices([])
setMappingId('')
setSuccess(null)
setError(null)
setLastImport(null)
setDiagnoseResult(null)
void runAnalyze(f)
}
const handleImport = async () => {
if (!file || !mappingId) {
setError('Bitte Datei und Vorlage wählen')
return
}
if (!importAllowed) {
setError('Diese Vorlage wird vom Universal-Importer noch nicht unterstützt.')
return
}
setLoadingImport(true)
setError(null)
setSuccess(null)
try {
const res = await api.importUniversalCsv(file, Number(mappingId))
setLastImport(res)
const st = res.stats || {}
const modLabel = MODULE_LABEL[res.module] || res.module || ''
setSuccess(
(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')
} finally {
setLoadingImport(false)
}
}
const runDiagnose = async () => {
if (!file || !mappingId) {
setError('Bitte Datei und Vorlage wählen')
return
}
setLoadingDiagnose(true)
setError(null)
setDiagnoseResult(null)
try {
const res = await api.diagnoseUniversalCsv(file, Number(mappingId))
setDiagnoseResult(res)
} catch (e) {
setError(e.message || 'Diagnose fehlgeschlagen')
} finally {
setLoadingDiagnose(false)
}
}
return (
<div className="capture-page" style={{ paddingBottom: 88 }}>
<button
type="button"
onClick={() => navigate(-1)}
className="btn btn-secondary"
style={{
marginBottom: 16,
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
}}
>
<ArrowLeft size={18} /> Zurück
</button>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<FileSpreadsheet size={26} strokeWidth={2} />
CSV-Import
</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
CSV hier ablegen oder die Fläche antippen die Analyse startet sofort. Die App vergleicht die Spalten
mit gespeicherten Vorlagen (Ernährung, Gewicht, Blutdruck, Aktivität, Schlaf, Vitalwerte) und schlägt
passende Ziele vor. Du bestätigst die Vorlage ohne zuerst ein Modell raten zu müssen. Schlaf-Import
erwartet das Apple-Health-Schlaf-CSV (Segment- oder Zusammenfassungs-Export).{' '}
<strong>Ausblick:</strong> Eine Datei mehrere Zieltabellen.
</p>
{error && (
<div
className="card"
style={{
marginBottom: 16,
padding: 12,
borderColor: 'var(--danger)',
color: 'var(--danger)',
}}
>
{error}
</div>
)}
{success && (
<div
className="card"
style={{
marginBottom: 16,
padding: 12,
borderColor: 'var(--accent)',
color: 'var(--accent-dark)',
}}
>
{success}
</div>
)}
{lastImport?.error_details?.length > 0 && (
<details
className="card"
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
open
>
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) zum Kopieren aufklappen
</summary>
<pre
style={{
marginTop: 12,
fontSize: 12,
overflow: 'auto',
maxHeight: 320,
background: 'var(--surface2)',
padding: 12,
borderRadius: 8,
color: 'var(--text1)',
}}
>
{JSON.stringify(lastImport.error_details, null, 2)}
</pre>
</details>
)}
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">1. CSV-Datei</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="form-input"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
onChange={(e) => assignCsvFile(e.target.files?.[0] || null)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={(e) => {
e.preventDefault()
if (!e.currentTarget.contains(e.relatedTarget)) setDragActive(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragActive(false)
assignCsvFile(e.dataTransfer?.files?.[0] || null)
}}
className="btn btn-secondary"
style={{
marginTop: 8,
width: '100%',
minHeight: 120,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
border: `2px dashed ${dragActive ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 12,
background: dragActive ? 'var(--surface2)' : 'var(--surface)',
cursor: 'pointer',
padding: 16,
}}
>
<Upload size={28} strokeWidth={1.75} color="var(--accent)" />
<span style={{ fontSize: 15, color: 'var(--text1)', fontWeight: 600 }}>
Datei ablegen oder tippen zum Auswählen
</span>
<span style={{ fontSize: 13, color: 'var(--text3)' }}>
{loadingAnalyze ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Loader2 size={16} style={{ animation: 'spin 0.7s linear infinite' }} />
Datei wird analysiert
</span>
) : file ? (
file.name
) : (
'Noch keine Datei gewählt'
)}
</span>
</button>
{file && !loadingAnalyze && (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12, width: '100%' }}
onClick={() => void runAnalyze(file)}
>
Dieselbe Datei erneut analysieren
</button>
)}
</div>
{analyzeResult && (
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">2. Erkennung &amp; Vorschau</div>
{(analyzeResult.warnings || []).length > 0 && (
<div
style={{
marginTop: 12,
padding: 12,
borderRadius: 12,
background: 'rgba(216, 90, 48, 0.12)',
border: '1px solid var(--danger)',
color: 'var(--danger-dark, var(--danger))',
fontSize: 14,
lineHeight: 1.5,
}}
>
{(analyzeResult.warnings || []).map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten:{' '}
{analyzeResult.columns?.length ?? 0}
{analyzeResult.format_detection?.apple_sleep ? (
<>
{' '}
· <strong>Format:</strong> Apple-Schlaf (Schlafanalyse oder Segment-Export)
</>
) : null}
</div>
{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}.
<br />
<span style={{ fontSize: 13, color: 'var(--text2)', fontWeight: 500 }}>
Vorlage abgedeckt:{' '}
<strong>{Math.round((analyzeResult.recommended.confidence || 0) * 100)} %</strong>
{analyzeResult.recommended.columns_matched != null &&
analyzeResult.recommended.columns_in_template != null
? ` (${analyzeResult.recommended.columns_matched}/${analyzeResult.recommended.columns_in_template} erwartete Spalten in der Datei)`
: ''}
.{' '}
{analyzeResult.recommended.jaccard != null && (
<>
Jaccard{' '}
<strong>{Math.round(analyzeResult.recommended.jaccard * 100)} %</strong> (gesamte
Spalten-Überlappung niedriger, wenn die CSV viele Zusatzspalten hat).
</>
)}
</span>
</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}>
{MODULE_LABEL[d.module] || d.module}: {d.mapping_name} · Vorlage{' '}
{Math.round((d.confidence || 0) * 100)} %
{d.jaccard != null ? ` · Jaccard ${Math.round(d.jaccard * 100)} %` : ''}
</li>
))}
</ul>
</details>
)}
<SampleTable sampleRows={analyzeResult.sample_rows} columns={analyzeResult.columns} />
<div className="form-label" style={{ marginTop: 16 }}>
Import-Vorlage
</div>
<select
className="form-input"
value={mappingId}
onChange={(e) => setMappingId(e.target.value)}
style={{
width: '100%',
marginTop: 8,
minHeight: 48,
textAlign: 'left',
padding: '12px 14px',
fontSize: 15,
}}
>
{mappingChoices.length === 0 ? (
<option value="">Keine Vorlage geladen</option>
) : (
mappingChoices.map((o) => (
<option key={o.id} value={o.id}>
{MODULE_LABEL[o.module] || o.module} {o.name}
{o.is_system ? ' (System)' : ''}
{o.confidence > 0
? ` · Vorlage ${Math.round(o.confidence * 100)} %${
o.jaccard != null ? ` · Jaccard ${Math.round(o.jaccard * 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; dieser Import-Weg unterstützt das Zielmodul noch nicht.
</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 16 }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={!file || !mappingId || !importAllowed || loadingDiagnose}
onClick={() => void runDiagnose()}
>
{loadingDiagnose ? (
<>
<Loader2
size={18}
style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }}
/>{' '}
Diagnose
</>
) : (
'Mapping prüfen (ohne Import)'
)}
</button>
<button
type="button"
className="btn btn-primary"
style={{ width: '100%' }}
disabled={!file || !mappingId || !importAllowed || loadingImport}
onClick={handleImport}
>
{loadingImport ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import
läuft
</>
) : (
'Import starten'
)}
</button>
</div>
{diagnoseResult && (
<details style={{ marginTop: 20 }} open>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text2)' }}>
Diagnose-Ergebnis ({diagnoseResult.rows_diagnosed ?? 0} Zeilen)
</summary>
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 8, lineHeight: 1.5 }}>
Vorlage #{diagnoseResult.mapping_id} · {diagnoseResult.mapping_name} · Modul{' '}
{MODULE_LABEL[diagnoseResult.module] || diagnoseResult.module}. Hinweise: Vitalwerte{' '}
<code>vitals.*</code>, Blutdruck <code>blood_pressure.*</code>, Workouts{' '}
<code>activity.*</code> (z.B. <code>would_pass_row_gate</code> /{' '}
<code>prefilter_fail_reason</code>).
</p>
<pre
style={{
marginTop: 8,
fontSize: 11,
overflow: 'auto',
maxHeight: 480,
background: 'var(--surface2)',
padding: 12,
borderRadius: 8,
color: 'var(--text1)',
}}
>
{JSON.stringify(diagnoseResult, null, 2)}
</pre>
</details>
)}
</div>
)}
</div>
)
}