mitai-jinkendo/frontend/src/pages/UniversalCsvImportPage.jsx

485 lines
16 KiB
React
Raw Normal View History

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 [error, setError] = useState(null)
const [success, setSuccess] = 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)
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))
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)
}
}
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>
)}
<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>
)}
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 16, 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>
)}
</div>
)
}