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

666 lines
23 KiB
React
Raw Normal View History

import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Loader2, Save, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
const MODULE_LABEL = {
nutrition: 'Ernährung',
weight: 'Gewicht',
blood_pressure: 'Blutdruck',
activity: 'Aktivität',
sleep: 'Schlaf',
vitals_baseline: 'Vitalwerte (Baseline)',
}
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 AdminCsvTemplateEditorPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const isNew = routeId === 'new'
const templateId = isNew ? null : Number(routeId)
const [modules, setModules] = useState([])
const [module, setModule] = useState('nutrition')
const [mappingName, setMappingName] = useState('')
const [description, setDescription] = useState('')
const [delimiter, setDelimiter] = useState(';')
const [encoding, setEncoding] = useState('utf-8')
const [hasHeader, setHasHeader] = useState(true)
const [columnSignature, setColumnSignature] = useState([])
const [columns, setColumns] = useState([])
const [fieldMappings, setFieldMappings] = useState({})
const [typeConversionsText, setTypeConversionsText] = useState('{}')
const [sampleRows, setSampleRows] = useState([])
const [seedHint, setSeedHint] = useState(null)
const [file, setFile] = useState(null)
const [delimiterOverride, setDelimiterOverride] = useState('')
const [seedTemplateId, setSeedTemplateId] = useState('')
const [seedOptions, setSeedOptions] = useState([])
const [loading, setLoading] = useState(!isNew)
const [analyzing, setAnalyzing] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module])
const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate'
const targetOptions = useMemo(() => {
if (!modMeta?.fields || aggregateSleepImport) return []
return Object.entries(modMeta.fields).map(([key, meta]) => ({
value: key,
label: `${key}${meta.required ? ' *' : ''}`,
}))
}, [modMeta, aggregateSleepImport])
const requiredTargets = useMemo(() => {
if (!modMeta?.fields) return []
return Object.entries(modMeta.fields)
.filter(([, v]) => v.required)
.map(([k]) => k)
}, [modMeta])
useEffect(() => {
api
.getCsvModules()
.then((r) => setModules(r.modules || []))
.catch(() => {})
}, [])
useEffect(() => {
if (!module) return
api
.adminListCsvTemplates(module)
.then((d) => setSeedOptions(d.templates || []))
.catch(() => setSeedOptions([]))
}, [module])
useEffect(() => {
if (isNew || !templateId) return
let ok = true
setLoading(true)
setError(null)
api
.adminGetCsvTemplate(templateId)
.then((t) => {
if (!ok) return
setModule(t.module)
setMappingName(t.mapping_name || '')
setDescription(t.description || '')
setDelimiter(t.delimiter || ',')
setEncoding(t.encoding || 'utf-8')
setHasHeader(!!t.has_header)
setColumnSignature(t.column_signature || [])
const fm = t.field_mappings || {}
setFieldMappings(fm)
setColumns(Object.keys(fm))
setTypeConversionsText(JSON.stringify(t.type_conversions || {}, null, 2))
setSampleRows([])
setSeedHint(null)
})
.catch((e) => {
if (ok) setError(e.message)
})
.finally(() => {
if (ok) setLoading(false)
})
return () => {
ok = false
}
}, [isNew, templateId])
const assignedTargets = useMemo(() => {
return new Set(
Object.values(fieldMappings).filter((v) => v && v !== '-' && v !== '_skip'),
)
}, [fieldMappings])
const missingRequired = useMemo(() => {
return requiredTargets.filter((r) => !assignedTargets.has(r))
}, [requiredTargets, assignedTargets])
const unitTargets = useMemo(() => {
if (!modMeta?.fields || aggregateSleepImport) return []
const list = []
const seen = new Set()
for (const t of assignedTargets) {
const opts = modMeta.fields[t]?.source_unit_options
if (!opts?.length || seen.has(t)) continue
seen.add(t)
list.push({ field: t, options: opts })
}
return list.sort((a, b) => a.field.localeCompare(b.field))
}, [modMeta, assignedTargets, aggregateSleepImport])
const getSourceUnitSelectValue = (fieldKey) => {
let tc
try {
tc = JSON.parse(typeConversionsText || '{}')
} catch {
return null
}
const opts = modMeta?.fields?.[fieldKey]?.source_unit_options || []
const canonical = opts.find((o) => o.is_canonical)?.id || opts[0]?.id
const su = tc[fieldKey]?.source_unit
if (su && opts.some((o) => o.id === su)) return su
return canonical || ''
}
const updateSourceUnit = (fieldKey, sourceUnitId) => {
let tc
try {
tc = JSON.parse(typeConversionsText || '{}')
} catch {
setError('type_conversions: ungültiges JSON (Quelleinheit kann nicht gesetzt werden).')
return
}
const opts = modMeta?.fields?.[fieldKey]?.source_unit_options || []
const canonical = opts.find((o) => o.is_canonical)?.id
const base =
tc[fieldKey] && typeof tc[fieldKey] === 'object'
? { ...tc[fieldKey] }
: { type: 'float', decimal_separator: 'auto', flexible: true }
if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) {
delete base.source_unit
} else {
base.source_unit = sourceUnitId
}
tc[fieldKey] = base
setTypeConversionsText(JSON.stringify(tc, null, 2))
setError(null)
}
const handleAnalyze = async () => {
if (!file) {
setError('Bitte eine CSV-Datei wählen.')
return
}
setAnalyzing(true)
setError(null)
try {
const delim = delimiterOverride || null
const seed = seedTemplateId === '' ? null : Number(seedTemplateId)
const res = await api.adminAnalyzeCsvTemplate(file, module, delim, seed)
setColumns(res.columns || [])
setColumnSignature(res.column_signature_normalized || [])
setFieldMappings(res.field_mappings || {})
setTypeConversionsText(JSON.stringify(res.type_conversions || {}, null, 2))
setSampleRows(res.sample_rows || [])
setDelimiter(res.delimiter || ';')
setEncoding(res.encoding || 'utf-8')
setSeedHint(res.seed_template || null)
} catch (e) {
setError(e.message || 'Analyse fehlgeschlagen')
} finally {
setAnalyzing(false)
}
}
const updateMapping = (col, dbField) => {
setFieldMappings((prev) => ({ ...prev, [col]: dbField || '-' }))
}
const handleSave = async () => {
setError(null)
let tc = null
try {
tc = JSON.parse(typeConversionsText || '{}')
if (tc !== null && typeof tc !== 'object') throw new Error()
} catch {
setError('type_conversions: ungültiges JSON.')
return
}
if (!mappingName.trim()) {
setError('Bitte einen Namen für die Vorlage eingeben.')
return
}
if (!columns.length || !Object.keys(fieldMappings).length) {
setError('Keine Spalten-Zuordnung: CSV analysieren oder Vorlage laden.')
return
}
if (missingRequired.length) {
setError(`Pflicht-Zielfelder fehlen: ${missingRequired.join(', ')}`)
return
}
const payload = {
module,
mapping_name: mappingName.trim(),
description: description.trim() || null,
column_signature: columnSignature.length ? columnSignature : null,
delimiter,
encoding: encoding || 'utf-8',
has_header: hasHeader,
field_mappings: fieldMappings,
type_conversions: tc,
}
if (!payload.column_signature?.length) {
setError('column_signature fehlt — bitte CSV erneut analysieren.')
return
}
setSaving(true)
try {
if (isNew) {
await api.adminCreateCsvTemplate(payload)
} else {
const { module: _m, ...patch } = payload
await api.adminUpdateCsvTemplate(templateId, patch)
}
navigate('/admin/csv-templates')
} catch (e) {
setError(e.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (isNew || !templateId) return
if (!confirm('System-Vorlage wirklich löschen?')) return
setError(null)
try {
await api.adminDeleteCsvTemplate(templateId)
navigate('/admin/csv-templates')
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen')
}
}
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Loader2 size={28} className="spin" style={{ animation: 'spin 0.8s linear infinite' }} />
</div>
)
}
return (
<div style={{ padding: '16px 16px 96px', maxWidth: 920, margin: '0 auto' }}>
<Link
to="/admin/csv-templates"
className="btn btn-secondary"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 16, textDecoration: 'none' }}
>
<ArrowLeft size={18} /> Zur Liste
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<FileSpreadsheet size={26} strokeWidth={2} />
{isNew ? 'Neue CSV-Vorlage' : 'Vorlage bearbeiten'}
</h1>
{error && (
<div className="card" style={{ padding: 12, borderColor: 'var(--danger)', color: 'var(--danger)', marginBottom: 16 }}>
{error}
</div>
)}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">Modul</div>
<select
className="form-input"
value={module}
disabled={!isNew}
onChange={(e) => setModule(e.target.value)}
style={{ width: '100%', marginTop: 8, textAlign: 'left', minHeight: 46, padding: '11px 14px' }}
>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{MODULE_LABEL[m.id] || m.id}
</option>
))}
</select>
{!isNew && (
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 8 }}>
Modul bestehender Vorlagen kann nicht geändert werden. System-Vorlagen können hier bearbeitet und
gespeichert werden (Signatur, Trennzeichen, Zuordnungen).
</p>
)}
{aggregateSleepImport && (
<p
style={{
fontSize: 13,
color: 'var(--text2)',
marginTop: 12,
lineHeight: 1.55,
padding: 12,
background: 'var(--surface2)',
borderRadius: 12,
}}
>
<strong>Schlaf (Apple-Aggregat):</strong> Die Zeilen werden nicht über Spalten-Ziele importiert,
sondern vom Apple-Schlaf-Parser ausgewertet (Schlafanalyse oder Segment-Export). Alle CSV-Spalten
bleiben auf „ignorieren“. Wichtig sind die{' '}
<strong>gespeicherte Spalten-Signatur</strong> und das passende Datei-Format — damit die Datei
in der Nutzer-Auswahl erkannt wird.
</p>
)}
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">1. Beispiel-CSV (wie Import)</div>
<input
type="file"
accept=".csv,text/csv"
className="form-input"
style={{ marginTop: 8, width: '100%' }}
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<div style={{ marginTop: 12, display: 'grid', gap: 12 }}>
<label style={{ fontSize: 14, color: 'var(--text2)' }}>
Trennzeichen (optional, sonst automatisch):
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={delimiterOverride}
onChange={(e) => setDelimiterOverride(e.target.value)}
>
<option value="">Auto</option>
<option value=";">Semikolon</option>
<option value=",">Komma</option>
<option value="\t">Tab</option>
</select>
</label>
<label style={{ fontSize: 14, color: 'var(--text2)' }}>
Optional: feste Seed-Vorlage für Vorschläge:
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={seedTemplateId}
onChange={(e) => setSeedTemplateId(e.target.value)}
>
<option value="">Beste passende System-Vorlage (Abdeckung der Vorlagen-Spalten)</option>
{seedOptions.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.mapping_name}
</option>
))}
</select>
</label>
</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 16, width: '100%' }}
disabled={!file || analyzing}
onClick={handleAnalyze}
>
{analyzing ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Analysiere …
</>
) : (
'CSV analysieren & Vorschläge'
)}
</button>
{seedHint && (
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 12, lineHeight: 1.5 }}>
Seed: <strong>{seedHint.mapping_name}</strong> · Vorlage abgedeckt{' '}
<strong>{Math.round((seedHint.confidence || 0) * 100)} %</strong>
{seedHint.columns_matched != null && seedHint.columns_in_template != null
? ` (${seedHint.columns_matched}/${seedHint.columns_in_template} Spalten)`
: ''}
{seedHint.jaccard != null && (
<>
{' '}
· Jaccard <strong>{Math.round(seedHint.jaccard * 100)} %</strong>
</>
)}
</p>
)}
{sampleRows.length > 0 && <SampleTable sampleRows={sampleRows} columns={columns} />}
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">2. Stammdaten</div>
<label className="form-label" style={{ marginTop: 12 }}>
Name *
</label>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
value={mappingName}
onChange={(e) => setMappingName(e.target.value)}
placeholder="z. B. FDDB Export 2026"
/>
<label className="form-label" style={{ marginTop: 12 }}>
Beschreibung
</label>
<textarea
className="form-input"
style={{ width: '100%', minHeight: 64, textAlign: 'left' }}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<label>
<span className="form-label">Trennzeichen (gespeichert)</span>
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={delimiter}
onChange={(e) => setDelimiter(e.target.value)}
>
<option value=";">Semikolon</option>
<option value=",">Komma</option>
<option value="\t">Tab</option>
</select>
</label>
<label>
<span className="form-label">Kopfzeile</span>
<select
className="form-input"
style={{ width: '100%', marginTop: 6, textAlign: 'left', minHeight: 44, padding: '10px 12px' }}
value={hasHeader ? 'yes' : 'no'}
onChange={(e) => setHasHeader(e.target.value === 'yes')}
>
<option value="yes">Ja</option>
<option value="no">Nein (nicht unterstützt im Import)</option>
</select>
</label>
</div>
<label className="form-label" style={{ marginTop: 16, display: 'block' }}>
Spalten-Signatur (Vorlagen-Matching — eine Original-Überschrift pro Zeile, manuell anpassbar)
</label>
<textarea
className="form-input"
style={{
width: '100%',
minHeight: 100,
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
textAlign: 'left',
}}
value={columnSignature.join('\n')}
onChange={(e) =>
setColumnSignature(
e.target.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder={'Date/Time\nStart\nEnd\nTotal Sleep (hr)\n…'}
/>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3. Spalten → Zielfelder (* = Pflicht)</div>
{!columns.length ? (
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 8 }}>
Nach CSV-Analyse erscheinen die Zeilen hier. Bei Schlaf-Vorlagen ohne Analyse: Signatur oben pflegen,
speichern, oder Beispiel-CSV analysieren.
</p>
) : (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{columns.map((col) => (
<div
key={col}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, min(52vw, 440px))',
gap: '10px 16px',
alignItems: 'center',
}}
>
<code style={{ fontSize: 12, wordBreak: 'break-word', color: 'var(--text2)', textAlign: 'left' }}>
{col}
</code>
<select
className="form-input"
value={fieldMappings[col] || '-'}
onChange={(e) => updateMapping(col, e.target.value)}
disabled={aggregateSleepImport}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
<option value="-">— ignorieren</option>
{targetOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
))}
</div>
)}
{missingRequired.length > 0 && (
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 12 }}>
Noch zuzuweisen: {missingRequired.join(', ')}
</p>
)}
</div>
{unitTargets.length > 0 && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3b. Quelleinheit (optional)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.55 }}>
Ziel-Einheit kommt aus dem Datenmodell (z. B. kcal, g, kg, km). Hier nur angeben, falls die CSV
abweicht (z. B. FDDB kJ → kcal, Makros in kg → g, Gewicht in lb → kg).
</p>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
{unitTargets.map(({ field: fkey, options }) => (
<label key={fkey} style={{ display: 'block' }}>
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
{fkey}
</span>
<select
className="form-input"
value={getSourceUnitSelectValue(fkey) || ''}
onChange={(e) => updateSourceUnit(fkey, e.target.value)}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</label>
))}
</div>
</div>
)}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">4. type_conversions (JSON)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
Vom Vorschlag übernommen; Quelleinheiten setzen zusätzlich <code>source_unit</code> (siehe 3b).
Manuell z. B. Datumsformat oder legacy <code>conversion_factor</code>.
</p>
<textarea
className="form-input"
style={{
width: '100%',
minHeight: 200,
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
textAlign: 'left',
}}
value={typeConversionsText}
onChange={(e) => setTypeConversionsText(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave} style={{ flex: 1, minWidth: 160 }}>
{saving ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Speichern …
</>
) : (
<>
<Save size={18} style={{ marginRight: 8 }} />
Speichern
</>
)}
</button>
{!isNew && (
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}>
<Trash2 size={18} style={{ marginRight: 8 }} />
Löschen
</button>
)}
</div>
</div>
)
}