feat(csv-import): Add Universal CSV Import page and navigation tile
- Introduced a new route for the Universal CSV Import page in App.jsx. - Added a corresponding navigation tile in captureNav.js for easy access to the CSV import functionality.
This commit is contained in:
parent
66979f3f51
commit
7e9da46fe5
|
|
@ -53,6 +53,7 @@ import RestDaysPage from './pages/RestDaysPage'
|
||||||
import VitalsPage from './pages/VitalsPage'
|
import VitalsPage from './pages/VitalsPage'
|
||||||
import GoalsPage from './pages/GoalsPage'
|
import GoalsPage from './pages/GoalsPage'
|
||||||
import CustomGoalsPage from './pages/CustomGoalsPage'
|
import CustomGoalsPage from './pages/CustomGoalsPage'
|
||||||
|
import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
|
||||||
import WorkflowEditorPage from './pages/WorkflowEditorPage'
|
import WorkflowEditorPage from './pages/WorkflowEditorPage'
|
||||||
import DesktopSidebar from './components/DesktopSidebar'
|
import DesktopSidebar from './components/DesktopSidebar'
|
||||||
import { getMainNavItems } from './config/appNav'
|
import { getMainNavItems } from './config/appNav'
|
||||||
|
|
@ -225,6 +226,7 @@ function AppShell() {
|
||||||
<Route path="/vitals" element={<VitalsPage />} />
|
<Route path="/vitals" element={<VitalsPage />} />
|
||||||
<Route path="/custom-goals" element={<CustomGoalsPage />} />
|
<Route path="/custom-goals" element={<CustomGoalsPage />} />
|
||||||
<Route path="/nutrition" element={<NutritionPage />} />
|
<Route path="/nutrition" element={<NutritionPage />} />
|
||||||
|
<Route path="/csv-import" element={<UniversalCsvImportPage />} />
|
||||||
<Route path="/activity" element={<ActivityPage />} />
|
<Route path="/activity" element={<ActivityPage />} />
|
||||||
<Route path="/guide" element={<GuidePage />} />
|
<Route path="/guide" element={<GuidePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,13 @@ export const CAPTURE_HUB_TILES = [
|
||||||
to: '/nutrition',
|
to: '/nutrition',
|
||||||
color: '#EF9F27',
|
color: '#EF9F27',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: '📥',
|
||||||
|
label: 'CSV-Import',
|
||||||
|
sub: 'Vorlagen für Ernährung, Gewicht, Blutdruck',
|
||||||
|
to: '/csv-import',
|
||||||
|
color: '#2E7D32',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: '🏋️',
|
icon: '🏋️',
|
||||||
label: 'Aktivität',
|
label: 'Aktivität',
|
||||||
|
|
|
||||||
366
frontend/src/pages/UniversalCsvImportPage.jsx
Normal file
366
frontend/src/pages/UniversalCsvImportPage.jsx
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
import { useState, useEffect, useCallback } 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'])
|
||||||
|
|
||||||
|
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 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 [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 handleAnalyze = async () => {
|
||||||
|
if (!file || !moduleId) {
|
||||||
|
setError('Bitte Modul und Datei wählen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoadingAnalyze(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const res = await api.analyzeCsv(file, moduleId)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if (pick != null) setMappingId(String(pick))
|
||||||
|
else setMappingId('')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Analyse fehlgeschlagen')
|
||||||
|
setAnalyzeResult(null)
|
||||||
|
} finally {
|
||||||
|
setLoadingAnalyze(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!file || !moduleId || !mappingId) {
|
||||||
|
setError('Bitte Datei, Modul und Vorlage wählen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoadingImport(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const res = await api.importUniversalCsv(file, moduleId, Number(mappingId))
|
||||||
|
const st = res.stats || {}
|
||||||
|
setSuccess(
|
||||||
|
`Import abgeschlossen: ${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.5 }}>
|
||||||
|
Bekannte Formate (FDDB, Apple Health, Omron, …) per Vorlage zuordnen und importieren.
|
||||||
|
Trainingseinheiten weiterhin unter{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '2px 8px', fontSize: 13 }}
|
||||||
|
onClick={() => navigate('/activity')}
|
||||||
|
>
|
||||||
|
Aktivität
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</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. 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>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="form-input"
|
||||||
|
style={{ marginTop: 8, width: '100%' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFile(e.target.files?.[0] || null)
|
||||||
|
setAnalyzeResult(null)
|
||||||
|
setSuccess(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ marginTop: 12, width: '100%' }}
|
||||||
|
disabled={!file || !moduleId || loadingAnalyze}
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
>
|
||||||
|
{loadingAnalyze ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Analysiere …
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Vorschau und Erkennung'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analyzeResult && (
|
||||||
|
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||||
|
<div className="form-label">3. Vorschau</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
|
||||||
|
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) => (
|
||||||
|
<li key={d.mapping_id}>
|
||||||
|
{d.mapping_name} · Übereinstimmung ca. {Math.round((d.confidence || 0) * 100)} %
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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 }}
|
||||||
|
>
|
||||||
|
{allMappingOptions.length === 0 ? (
|
||||||
|
<option value="">Keine Vorlage für dieses Modul</option>
|
||||||
|
) : (
|
||||||
|
allMappingOptions.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ marginTop: 16, width: '100%' }}
|
||||||
|
disabled={!file || !mappingId || loadingImport}
|
||||||
|
onClick={handleImport}
|
||||||
|
>
|
||||||
|
{loadingImport ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import läuft …
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Import starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user