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

367 lines
12 KiB
React
Raw Normal View History

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>
)
}