feat: Frontend Phase 3.1 - Focus Areas Admin UI
- AdminFocusAreasPage: Full CRUD for focus area definitions - Route: /admin/focus-areas - AdminPanel: Link zu Focus Areas (neben Goal Types) - api.js: 7 neue Focus Area Endpoints Features: - Category-grouped display (7 categories) - Inline editing - Active/Inactive toggle - Create form with validation - Show/Hide inactive areas Next: Goal Form Multi-Select
This commit is contained in:
parent
f312dd0dbb
commit
d14157f7ad
|
|
@ -32,6 +32,7 @@ import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
|||
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||
import SubscriptionPage from './pages/SubscriptionPage'
|
||||
import SleepPage from './pages/SleepPage'
|
||||
import RestDaysPage from './pages/RestDaysPage'
|
||||
|
|
@ -192,6 +193,7 @@ function AppShell() {
|
|||
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
<Route path="/admin/focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
|
|||
475
frontend/src/pages/AdminFocusAreasPage.jsx
Normal file
475
frontend/src/pages/AdminFocusAreasPage.jsx
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'body_composition', label: 'Körperzusammensetzung' },
|
||||
{ value: 'training', label: 'Training' },
|
||||
{ value: 'endurance', label: 'Ausdauer' },
|
||||
{ value: 'coordination', label: 'Koordination' },
|
||||
{ value: 'mental', label: 'Mental' },
|
||||
{ value: 'recovery', label: 'Erholung' },
|
||||
{ value: 'health', label: 'Gesundheit' },
|
||||
{ value: 'custom', label: 'Eigene' }
|
||||
]
|
||||
|
||||
export default function AdminFocusAreasPage() {
|
||||
const [data, setData] = useState({ areas: [], grouped: {}, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [showInactive, setShowInactive] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
category: 'custom'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [showInactive])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await api.listFocusAreaDefinitions(showInactive)
|
||||
setData(result)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load focus areas:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.key || !formData.name_de) {
|
||||
setError('Key und Name (DE) sind erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createFocusAreaDefinition(formData)
|
||||
setCreating(false)
|
||||
setFormData({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
category: 'custom'
|
||||
})
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (id) => {
|
||||
try {
|
||||
const area = data.areas.find(a => a.id === id)
|
||||
await api.updateFocusAreaDefinition(id, {
|
||||
name_de: area.name_de,
|
||||
name_en: area.name_en,
|
||||
icon: area.icon,
|
||||
description: area.description,
|
||||
category: area.category,
|
||||
is_active: area.is_active
|
||||
})
|
||||
setEditingId(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Focus Area wirklich löschen?')) return
|
||||
|
||||
try {
|
||||
await api.deleteFocusAreaDefinition(id)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (id) => {
|
||||
const area = data.areas.find(a => a.id === id)
|
||||
try {
|
||||
await api.updateFocusAreaDefinition(id, {
|
||||
is_active: !area.is_active
|
||||
})
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const updateField = (id, field, value) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
areas: prev.areas.map(a =>
|
||||
a.id === id ? { ...a, [field]: value } : a
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, paddingBottom: 80 }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>
|
||||
🎯 Focus Areas ({data.total})
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowInactive(!showInactive)}
|
||||
style={{ padding: '8px 12px', fontSize: 13 }}
|
||||
>
|
||||
{showInactive ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
{showInactive ? 'Inaktive ausblenden' : 'Inaktive anzeigen'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => setCreating(true)}
|
||||
style={{ padding: '8px 16px' }}
|
||||
>
|
||||
<Plus size={16} /> Neue Focus Area
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#FEE2E2',
|
||||
color: '#991B1B',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
fontSize: 14
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Form */}
|
||||
{creating && (
|
||||
<div className="card" style={{ marginBottom: 16, background: 'var(--accent-light)' }}>
|
||||
<h3 style={{ fontSize: 16, marginBottom: 12, color: 'var(--accent)' }}>
|
||||
Neue Focus Area
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Key (Eindeutig, z.B. "explosive_power")
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
placeholder="explosive_power"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Name (Deutsch) *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_de}
|
||||
onChange={(e) => setFormData({ ...formData, name_de: e.target.value })}
|
||||
placeholder="Explosivkraft"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Name (English)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_en}
|
||||
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
|
||||
placeholder="Explosive Power"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Icon (Emoji)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
||||
placeholder="💥"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{CATEGORIES.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kraft in kürzester Zeit explosiv entfalten"
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn-primary" onClick={handleCreate} style={{ flex: 1 }}>
|
||||
<Save size={14} /> Erstellen
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setCreating(false)
|
||||
setFormData({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
category: 'custom'
|
||||
})
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<X size={14} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped Areas */}
|
||||
{Object.entries(data.grouped).map(([category, areas]) => (
|
||||
<div key={category} style={{ marginBottom: 24 }}>
|
||||
<h2 style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text2)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
{CATEGORIES.find(c => c.value === category)?.label || category} ({areas.length})
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{areas.map(area => {
|
||||
const isEditing = editingId === area.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={area.id}
|
||||
className="card"
|
||||
style={{
|
||||
opacity: area.is_active ? 1 : 0.5,
|
||||
borderLeft: area.is_active
|
||||
? '4px solid var(--accent)'
|
||||
: '4px solid var(--border)'
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Name (DE)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={area.name_de}
|
||||
onChange={(e) => updateField(area.id, 'name_de', e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Icon
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={area.icon || ''}
|
||||
onChange={(e) => updateField(area.id, 'icon', e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={area.description || ''}
|
||||
onChange={(e) => updateField(area.id, 'description', e.target.value)}
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => handleUpdate(area.id)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Save size={14} /> Speichern
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setEditingId(null)
|
||||
loadData()
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<X size={14} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
{area.icon && <span>{area.icon}</span>}
|
||||
<span>{area.name_de}</span>
|
||||
{!area.is_active && (
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
background: 'var(--border)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Inaktiv
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 4 }}>
|
||||
Key: <code style={{
|
||||
background: 'var(--surface2)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 4
|
||||
}}>
|
||||
{area.key}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{area.description && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4 }}>
|
||||
{area.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => handleToggleActive(area.id)}
|
||||
style={{ padding: '6px 12px' }}
|
||||
title={area.is_active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
>
|
||||
{area.is_active ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => setEditingId(area.id)}
|
||||
style={{ padding: '6px 12px' }}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => handleDelete(area.id)}
|
||||
style={{ padding: '6px 12px', color: '#DC2626' }}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.areas.length === 0 && (
|
||||
<div style={{
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Keine Focus Areas vorhanden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -485,6 +485,23 @@ export default function AdminPanel() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focus Areas Section */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Focus Areas (v9g)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Focus Area Definitionen: Dynamisches, erweiterbares System mit 26+ Bereichen über 7 Kategorien.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/focus-areas">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎯 Focus Areas verwalten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,4 +365,13 @@ export const api = {
|
|||
// Fitness Tests
|
||||
listFitnessTests: () => req('/goals/tests'),
|
||||
createFitnessTest: (d) => req('/goals/tests', json(d)),
|
||||
|
||||
// Focus Areas (v2.0)
|
||||
listFocusAreaDefinitions: (includeInactive=false) => req(`/focus-areas/definitions?include_inactive=${includeInactive}`),
|
||||
createFocusAreaDefinition: (d) => req('/focus-areas/definitions', json(d)),
|
||||
updateFocusAreaDefinition: (id,d) => req(`/focus-areas/definitions/${id}`, jput(d)),
|
||||
deleteFocusAreaDefinition: (id) => req(`/focus-areas/definitions/${id}`, {method:'DELETE'}),
|
||||
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
|
||||
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
|
||||
getFocusAreaStats: () => req('/focus-areas/stats'),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user