mitai-jinkendo/frontend/src/pages/AdminActivityMappingsPage.jsx
Lars 3be82dc8c2
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: inline editing for activity mappings (improved UX)
- 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>
2026-03-21 19:46:11 +01:00

447 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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