- Edit form now appears at the position of the item being edited - No scrolling needed - stays at same location - Matches ActivityPage inline editing behavior - Visual indicator: Accent border when editing - Create form still appears at top (separate from list) Benefits: - Better UX - no need to scroll to top - Easier to find edited item after saving - Consistent with rest of app Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
447 lines
16 KiB
JavaScript
447 lines
16 KiB
JavaScript
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 */}
|
||
<button
|
||
onClick={startCreate}
|
||
className="btn btn-primary btn-full"
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Plus size={16} /> Neues Mapping anlegen
|
||
</button>
|
||
|
||
{/* New mapping form (only shown when creating) */}
|
||
{editingId === 'new' && formData && (
|
||
<div className="card" style={{ padding: 16, marginBottom: 16, border: '2px solid var(--accent)' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: 12 }}>➕ Neues Mapping</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"
|
||
style={{ width: '100%' }}
|
||
autoFocus
|
||
/>
|
||
<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 with inline editing */}
|
||
{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 => {
|
||
const isEditing = editingId === mapping.id
|
||
|
||
return (
|
||
<div
|
||
key={mapping.id}
|
||
className="card"
|
||
style={{
|
||
padding: 12,
|
||
border: isEditing ? '2px solid var(--accent)' : undefined
|
||
}}
|
||
>
|
||
{isEditing && formData ? (
|
||
/* Inline edit form */
|
||
<div>
|
||
<div style={{ fontWeight: 600, marginBottom: 12, color: 'var(--accent)' }}>
|
||
✏️ Mapping bearbeiten
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<div>
|
||
<div className="form-label">Activity Type (nicht änderbar)</div>
|
||
<input
|
||
className="form-input"
|
||
value={formData.activity_type}
|
||
disabled
|
||
style={{ width: '100%', background: 'var(--surface2)' }}
|
||
/>
|
||
</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>
|
||
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="btn btn-primary"
|
||
style={{ flex: 1 }}
|
||
>
|
||
{saving ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}>
|
||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||
Speichere...
|
||
</div>
|
||
) : (
|
||
<>
|
||
<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>
|
||
) : (
|
||
/* Normal view */
|
||
<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>
|
||
)
|
||
}
|