feat: Frontend Phase 3.1 - Focus Areas Admin UI
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

- 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:
Lars 2026-03-27 19:51:18 +01:00
parent f312dd0dbb
commit d14157f7ad
4 changed files with 503 additions and 0 deletions

View File

@ -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>

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

View File

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

View File

@ -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'),
}