367 lines
12 KiB
React
367 lines
12 KiB
React
|
|
import { useState, useEffect } from 'react'
|
|||
|
|
import { useNavigate } from 'react-router-dom'
|
|||
|
|
import { Pencil, Trash2, Plus, Save, X, ArrowLeft, TrendingUp } from 'lucide-react'
|
|||
|
|
import { api } from '../utils/api'
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* AdminActivityMappingsPage - Manage activity_type → training_type mappings
|
|||
|
|
* v9d Phase 1b - Learnable system (replaces hardcoded mappings)
|
|||
|
|
*/
|
|||
|
|
export default function AdminActivityMappingsPage() {
|
|||
|
|
const nav = useNavigate()
|
|||
|
|
const [mappings, setMappings] = useState([])
|
|||
|
|
const [trainingTypes, setTrainingTypes] = useState([])
|
|||
|
|
const [coverage, setCoverage] = useState(null)
|
|||
|
|
const [loading, setLoading] = useState(true)
|
|||
|
|
const [editingId, setEditingId] = useState(null)
|
|||
|
|
const [formData, setFormData] = useState(null)
|
|||
|
|
const [error, setError] = useState(null)
|
|||
|
|
const [saving, setSaving] = useState(false)
|
|||
|
|
const [filter, setFilter] = useState('all') // 'all', 'global', 'user'
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load()
|
|||
|
|
}, [filter])
|
|||
|
|
|
|||
|
|
const load = () => {
|
|||
|
|
setLoading(true)
|
|||
|
|
Promise.all([
|
|||
|
|
api.adminListActivityMappings(null, filter === 'global'),
|
|||
|
|
api.listTrainingTypesFlat(),
|
|||
|
|
api.adminGetMappingCoverage()
|
|||
|
|
]).then(([mappingsData, typesData, coverageData]) => {
|
|||
|
|
setMappings(mappingsData)
|
|||
|
|
setTrainingTypes(typesData)
|
|||
|
|
setCoverage(coverageData)
|
|||
|
|
setLoading(false)
|
|||
|
|
}).catch(err => {
|
|||
|
|
console.error('Failed to load mappings:', err)
|
|||
|
|
setError(err.message)
|
|||
|
|
setLoading(false)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const startCreate = () => {
|
|||
|
|
setFormData({
|
|||
|
|
activity_type: '',
|
|||
|
|
training_type_id: trainingTypes[0]?.id || null,
|
|||
|
|
profile_id: '',
|
|||
|
|
source: 'admin'
|
|||
|
|
})
|
|||
|
|
setEditingId('new')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const startEdit = (mapping) => {
|
|||
|
|
setFormData({
|
|||
|
|
activity_type: mapping.activity_type,
|
|||
|
|
training_type_id: mapping.training_type_id,
|
|||
|
|
profile_id: mapping.profile_id || '',
|
|||
|
|
source: mapping.source
|
|||
|
|
})
|
|||
|
|
setEditingId(mapping.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const cancelEdit = () => {
|
|||
|
|
setEditingId(null)
|
|||
|
|
setFormData(null)
|
|||
|
|
setError(null)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
if (!formData.activity_type || !formData.training_type_id) {
|
|||
|
|
setError('Activity Type und Training Type sind Pflichtfelder')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setSaving(true)
|
|||
|
|
setError(null)
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const payload = {
|
|||
|
|
...formData,
|
|||
|
|
profile_id: formData.profile_id || null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (editingId === 'new') {
|
|||
|
|
await api.adminCreateActivityMapping(payload)
|
|||
|
|
} else {
|
|||
|
|
await api.adminUpdateActivityMapping(editingId, {
|
|||
|
|
training_type_id: payload.training_type_id,
|
|||
|
|
profile_id: payload.profile_id,
|
|||
|
|
source: payload.source
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
await load()
|
|||
|
|
cancelEdit()
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Save failed:', err)
|
|||
|
|
setError(err.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleDelete = async (id, activityType) => {
|
|||
|
|
if (!confirm(`Mapping für "${activityType}" wirklich löschen?\n\nZukünftige Imports werden diesen Typ nicht mehr automatisch zuordnen.`)) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await api.adminDeleteActivityMapping(id)
|
|||
|
|
await load()
|
|||
|
|
} catch (err) {
|
|||
|
|
alert('Löschen fehlgeschlagen: ' + err.message)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div style={{ padding: 20, textAlign: 'center' }}>
|
|||
|
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const coveragePercent = coverage ? Math.round((coverage.mapped_activities / coverage.total_activities) * 100) : 0
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ padding: '16px 16px 80px' }}>
|
|||
|
|
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
|
|||
|
|
<button
|
|||
|
|
onClick={() => nav('/settings')}
|
|||
|
|
style={{
|
|||
|
|
background: 'none',
|
|||
|
|
border: 'none',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
padding: 6,
|
|||
|
|
display: 'flex',
|
|||
|
|
color: 'var(--text2)'
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<ArrowLeft size={20} />
|
|||
|
|
</button>
|
|||
|
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Activity-Mappings</h1>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{error && (
|
|||
|
|
<div className="card" style={{ padding: 12, marginBottom: 16, background: '#FCEBEB', color: '#D85A30' }}>
|
|||
|
|
{error}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Coverage Stats */}
|
|||
|
|
{coverage && (
|
|||
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|||
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|||
|
|
<TrendingUp size={16} color="var(--accent)" />
|
|||
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>Mapping-Abdeckung</div>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, fontSize: 12 }}>
|
|||
|
|
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
|
|||
|
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{coveragePercent}%</div>
|
|||
|
|
<div style={{ color: 'var(--text3)' }}>Zugeordnet</div>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
|
|||
|
|
<div style={{ fontSize: 18, fontWeight: 700 }}>{coverage.mapped_activities}</div>
|
|||
|
|
<div style={{ color: 'var(--text3)' }}>Mit Typ</div>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
|
|||
|
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#D85A30' }}>{coverage.unmapped_activities}</div>
|
|||
|
|
<div style={{ color: 'var(--text3)' }}>Ohne Typ</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)' }}>
|
|||
|
|
{coverage.unmapped_types} verschiedene Activity-Types noch nicht gemappt
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Filter */}
|
|||
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setFilter('all')}
|
|||
|
|
className={filter === 'all' ? 'btn btn-primary' : 'btn btn-secondary'}
|
|||
|
|
style={{ flex: 1, fontSize: 12 }}
|
|||
|
|
>
|
|||
|
|
Alle ({mappings.length})
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setFilter('global')}
|
|||
|
|
className={filter === 'global' ? 'btn btn-primary' : 'btn btn-secondary'}
|
|||
|
|
style={{ flex: 1, fontSize: 12 }}
|
|||
|
|
>
|
|||
|
|
Global
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Create new button */}
|
|||
|
|
{!editingId && (
|
|||
|
|
<button
|
|||
|
|
onClick={startCreate}
|
|||
|
|
className="btn btn-primary btn-full"
|
|||
|
|
style={{ marginBottom: 16 }}
|
|||
|
|
>
|
|||
|
|
<Plus size={16} /> Neues Mapping anlegen
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Edit form */}
|
|||
|
|
{editingId && formData && (
|
|||
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|||
|
|
<div style={{ fontWeight: 600, marginBottom: 12 }}>
|
|||
|
|
{editingId === 'new' ? '➕ Neues Mapping' : '✏️ Mapping bearbeiten'}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|||
|
|
<div>
|
|||
|
|
<div className="form-label">Activity Type * (exakt wie in CSV)</div>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={formData.activity_type}
|
|||
|
|
onChange={e => setFormData({ ...formData, activity_type: e.target.value })}
|
|||
|
|
placeholder="z.B. Traditionelles Krafttraining"
|
|||
|
|
disabled={editingId !== 'new'}
|
|||
|
|
style={{ width: '100%' }}
|
|||
|
|
/>
|
|||
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
|||
|
|
Groß-/Kleinschreibung beachten! Muss exakt mit CSV übereinstimmen.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<div className="form-label">Training Type *</div>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={formData.training_type_id}
|
|||
|
|
onChange={e => setFormData({ ...formData, training_type_id: parseInt(e.target.value) })}
|
|||
|
|
style={{ width: '100%' }}
|
|||
|
|
>
|
|||
|
|
{trainingTypes.map(type => (
|
|||
|
|
<option key={type.id} value={type.id}>
|
|||
|
|
{type.icon} {type.name_de} ({type.category})
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<div className="form-label">Profil-ID (leer = global)</div>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={formData.profile_id}
|
|||
|
|
onChange={e => setFormData({ ...formData, profile_id: e.target.value })}
|
|||
|
|
placeholder="Leer lassen für globales Mapping"
|
|||
|
|
style={{ width: '100%' }}
|
|||
|
|
/>
|
|||
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
|||
|
|
Global = für alle User, sonst user-spezifisch
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|||
|
|
<button
|
|||
|
|
onClick={handleSave}
|
|||
|
|
disabled={saving}
|
|||
|
|
className="btn btn-primary"
|
|||
|
|
style={{ flex: 1 }}
|
|||
|
|
>
|
|||
|
|
{saving ? (
|
|||
|
|
<>
|
|||
|
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
|||
|
|
Speichere...
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<Save size={16} /> Speichern
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={cancelEdit}
|
|||
|
|
disabled={saving}
|
|||
|
|
className="btn btn-secondary"
|
|||
|
|
style={{ flex: 1 }}
|
|||
|
|
>
|
|||
|
|
<X size={16} /> Abbrechen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* List */}
|
|||
|
|
{mappings.length === 0 ? (
|
|||
|
|
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
|
|||
|
|
Keine Mappings gefunden
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|||
|
|
{mappings.map(mapping => (
|
|||
|
|
<div
|
|||
|
|
key={mapping.id}
|
|||
|
|
className="card"
|
|||
|
|
style={{ padding: 12 }}
|
|||
|
|
>
|
|||
|
|
<div style={{
|
|||
|
|
display: 'flex',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
gap: 8
|
|||
|
|
}}>
|
|||
|
|
<div style={{ fontSize: 18 }}>{mapping.icon}</div>
|
|||
|
|
<div style={{ flex: 1 }}>
|
|||
|
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
|||
|
|
{mapping.activity_type}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
|||
|
|
→ {mapping.training_type_name_de}
|
|||
|
|
{mapping.profile_id && <> · User-spezifisch</>}
|
|||
|
|
{!mapping.profile_id && <> · Global</>}
|
|||
|
|
{mapping.source && <> · {mapping.source}</>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={() => startEdit(mapping)}
|
|||
|
|
style={{
|
|||
|
|
background: 'none',
|
|||
|
|
border: 'none',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
padding: 6,
|
|||
|
|
color: 'var(--accent)'
|
|||
|
|
}}
|
|||
|
|
title="Bearbeiten"
|
|||
|
|
>
|
|||
|
|
<Pencil size={16} />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleDelete(mapping.id, mapping.activity_type)}
|
|||
|
|
style={{
|
|||
|
|
background: 'none',
|
|||
|
|
border: 'none',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
padding: 6,
|
|||
|
|
color: '#D85A30'
|
|||
|
|
}}
|
|||
|
|
title="Löschen"
|
|||
|
|
>
|
|||
|
|
<Trash2 size={16} />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div style={{
|
|||
|
|
marginTop: 20,
|
|||
|
|
padding: 12,
|
|||
|
|
background: 'var(--surface2)',
|
|||
|
|
borderRadius: 8,
|
|||
|
|
fontSize: 12,
|
|||
|
|
color: 'var(--text3)'
|
|||
|
|
}}>
|
|||
|
|
<strong>💡 Tipp:</strong> Das System lernt automatisch! Wenn du im Tab "Kategorisieren" Aktivitäten zuordnest, wird das Mapping gespeichert und beim nächsten Import automatisch angewendet.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|