Compare commits
2 Commits
2f51b26418
...
caebc37da0
| Author | SHA1 | Date | |
|---|---|---|---|
| caebc37da0 | |||
| 6a3a782bff |
58
backend/migrations/028_goal_categories_priorities.sql
Normal file
58
backend/migrations/028_goal_categories_priorities.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- Migration 028: Goal Categories and Priorities
|
||||||
|
-- Date: 2026-03-27
|
||||||
|
-- Purpose: Multi-dimensional goal priorities (one primary goal per category)
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Add category and priority columns
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE goals
|
||||||
|
ADD COLUMN category VARCHAR(50),
|
||||||
|
ADD COLUMN priority INTEGER DEFAULT 2 CHECK (priority >= 1 AND priority <= 3);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN goals.category IS 'Goal category: body, training, nutrition, recovery, health, other';
|
||||||
|
COMMENT ON COLUMN goals.priority IS 'Priority level: 1=high, 2=medium, 3=low';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Migrate existing goals to categories based on goal_type
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
UPDATE goals SET category = CASE
|
||||||
|
-- Body composition goals
|
||||||
|
WHEN goal_type IN ('weight', 'body_fat', 'lean_mass') THEN 'body'
|
||||||
|
|
||||||
|
-- Training goals
|
||||||
|
WHEN goal_type IN ('strength', 'flexibility', 'training_frequency') THEN 'training'
|
||||||
|
|
||||||
|
-- Health/cardio goals
|
||||||
|
WHEN goal_type IN ('vo2max', 'rhr', 'bp', 'hrv') THEN 'health'
|
||||||
|
|
||||||
|
-- Recovery goals
|
||||||
|
WHEN goal_type IN ('sleep_quality', 'sleep_duration', 'rest_days') THEN 'recovery'
|
||||||
|
|
||||||
|
-- Nutrition goals
|
||||||
|
WHEN goal_type IN ('calories', 'protein', 'healthy_eating') THEN 'nutrition'
|
||||||
|
|
||||||
|
-- Default
|
||||||
|
ELSE 'other'
|
||||||
|
END
|
||||||
|
WHERE category IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Set priority based on is_primary
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
UPDATE goals SET priority = CASE
|
||||||
|
WHEN is_primary = true THEN 1 -- Primary goals get priority 1
|
||||||
|
ELSE 2 -- Others get priority 2 (medium)
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Create index for category-based queries
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_goals_category_priority
|
||||||
|
ON goals(profile_id, category, priority)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_goals_category_priority IS 'Fast lookup for category-grouped goals sorted by priority';
|
||||||
|
|
@ -42,10 +42,12 @@ class FocusAreasUpdate(BaseModel):
|
||||||
class GoalCreate(BaseModel):
|
class GoalCreate(BaseModel):
|
||||||
"""Create or update a concrete goal"""
|
"""Create or update a concrete goal"""
|
||||||
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
|
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
|
||||||
is_primary: bool = False
|
is_primary: bool = False # Kept for backward compatibility
|
||||||
target_value: float
|
target_value: float
|
||||||
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
|
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
|
||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
|
category: Optional[str] = 'other' # body, training, nutrition, recovery, health, other
|
||||||
|
priority: Optional[int] = 2 # 1=high, 2=medium, 3=low
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -54,7 +56,9 @@ class GoalUpdate(BaseModel):
|
||||||
target_value: Optional[float] = None
|
target_value: Optional[float] = None
|
||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
status: Optional[str] = None # active, reached, abandoned, expired
|
status: Optional[str] = None # active, reached, abandoned, expired
|
||||||
is_primary: Optional[bool] = None
|
is_primary: Optional[bool] = None # Kept for backward compatibility
|
||||||
|
category: Optional[str] = None # body, training, nutrition, recovery, health, other
|
||||||
|
priority: Optional[int] = None # 1=high, 2=medium, 3=low
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -403,13 +407,13 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
|
||||||
INSERT INTO goals (
|
INSERT INTO goals (
|
||||||
profile_id, goal_type, is_primary,
|
profile_id, goal_type, is_primary,
|
||||||
target_value, current_value, start_value, unit,
|
target_value, current_value, start_value, unit,
|
||||||
target_date, name, description
|
target_date, category, priority, name, description
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
pid, data.goal_type, data.is_primary,
|
pid, data.goal_type, data.is_primary,
|
||||||
data.target_value, current_value, current_value, data.unit,
|
data.target_value, current_value, current_value, data.unit,
|
||||||
data.target_date, data.name, data.description
|
data.target_date, data.category, data.priority, data.name, data.description
|
||||||
))
|
))
|
||||||
|
|
||||||
goal_id = cur.fetchone()['id']
|
goal_id = cur.fetchone()['id']
|
||||||
|
|
@ -461,6 +465,14 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_
|
||||||
updates.append("is_primary = %s")
|
updates.append("is_primary = %s")
|
||||||
params.append(data.is_primary)
|
params.append(data.is_primary)
|
||||||
|
|
||||||
|
if data.category is not None:
|
||||||
|
updates.append("category = %s")
|
||||||
|
params.append(data.category)
|
||||||
|
|
||||||
|
if data.priority is not None:
|
||||||
|
updates.append("priority = %s")
|
||||||
|
params.append(data.priority)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
updates.append("name = %s")
|
updates.append("name = %s")
|
||||||
params.append(data.name)
|
params.append(data.name)
|
||||||
|
|
@ -499,6 +511,52 @@ def delete_goal(goal_id: str, session: dict = Depends(require_auth)):
|
||||||
|
|
||||||
return {"message": "Ziel gelöscht"}
|
return {"message": "Ziel gelöscht"}
|
||||||
|
|
||||||
|
@router.get("/grouped")
|
||||||
|
def get_goals_grouped(session: dict = Depends(require_auth)):
|
||||||
|
"""
|
||||||
|
Get goals grouped by category, sorted by priority.
|
||||||
|
|
||||||
|
Returns structure:
|
||||||
|
{
|
||||||
|
"body": [{"id": "...", "goal_type": "weight", "priority": 1, ...}, ...],
|
||||||
|
"training": [...],
|
||||||
|
"nutrition": [...],
|
||||||
|
"recovery": [...],
|
||||||
|
"health": [...],
|
||||||
|
"other": [...]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pid = session['profile_id']
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Get all active goals with type definitions
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
g.id, g.goal_type, g.target_value, g.current_value, g.start_value,
|
||||||
|
g.unit, g.target_date, g.status, g.is_primary, g.category, g.priority,
|
||||||
|
g.name, g.description, g.progress_pct, g.linear_projection,
|
||||||
|
g.created_at, g.updated_at,
|
||||||
|
gt.label_de, gt.icon, gt.category as type_category
|
||||||
|
FROM goals g
|
||||||
|
LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key
|
||||||
|
WHERE g.profile_id = %s AND g.is_active = true
|
||||||
|
ORDER BY g.category, g.priority ASC, g.created_at DESC
|
||||||
|
""", (pid,))
|
||||||
|
|
||||||
|
goals = cur.fetchall()
|
||||||
|
|
||||||
|
# Group by category
|
||||||
|
grouped = {}
|
||||||
|
for goal in goals:
|
||||||
|
cat = goal['category'] or 'other'
|
||||||
|
if cat not in grouped:
|
||||||
|
grouped[cat] = []
|
||||||
|
grouped[cat].append(r2d(goal))
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Training Phases
|
# Training Phases
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -5,44 +5,22 @@ import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
||||||
// Goal Mode Definitions
|
// Goal Categories
|
||||||
const GOAL_MODES = [
|
const GOAL_CATEGORIES = {
|
||||||
{
|
body: { label: 'Körper', icon: '📉', color: '#D85A30', description: 'Gewicht, Körperfett, Muskelmasse' },
|
||||||
id: 'weight_loss',
|
training: { label: 'Training', icon: '🏋️', color: '#378ADD', description: 'Kraft, Frequenz, Performance' },
|
||||||
icon: '📉',
|
nutrition: { label: 'Ernährung', icon: '🍎', color: '#1D9E75', description: 'Kalorien, Makros, Essgewohnheiten' },
|
||||||
label: 'Gewichtsreduktion',
|
recovery: { label: 'Erholung', icon: '😴', color: '#7B68EE', description: 'Schlaf, Regeneration, Ruhetage' },
|
||||||
description: 'Kaloriendefizit, Fettabbau',
|
health: { label: 'Gesundheit', icon: '❤️', color: '#E67E22', description: 'Vitalwerte, Blutdruck, HRV' },
|
||||||
color: '#D85A30'
|
other: { label: 'Sonstiges', icon: '📌', color: '#94A3B8', description: 'Weitere Ziele' }
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 'strength',
|
// Priority Levels
|
||||||
icon: '💪',
|
const PRIORITY_LEVELS = {
|
||||||
label: 'Kraftaufbau',
|
1: { label: 'Hoch', stars: '⭐⭐⭐', color: 'var(--accent)' },
|
||||||
description: 'Muskelwachstum, progressive Belastung',
|
2: { label: 'Mittel', stars: '⭐⭐', color: '#94A3B8' },
|
||||||
color: '#378ADD'
|
3: { label: 'Niedrig', stars: '⭐', color: '#CBD5E1' }
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 'endurance',
|
|
||||||
icon: '🏃',
|
|
||||||
label: 'Ausdauer',
|
|
||||||
description: 'VO2Max, aerobe Kapazität',
|
|
||||||
color: '#1D9E75'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'recomposition',
|
|
||||||
icon: '⚖️',
|
|
||||||
label: 'Körperkomposition',
|
|
||||||
description: 'Gleichzeitig Fett ab- & Muskeln aufbauen',
|
|
||||||
color: '#7B68EE'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'health',
|
|
||||||
icon: '❤️',
|
|
||||||
label: 'Allgemeine Gesundheit',
|
|
||||||
description: 'Ausgewogen, präventiv',
|
|
||||||
color: '#E67E22'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function GoalsPage() {
|
export default function GoalsPage() {
|
||||||
const [goalMode, setGoalMode] = useState(null)
|
const [goalMode, setGoalMode] = useState(null)
|
||||||
|
|
@ -56,7 +34,8 @@ export default function GoalsPage() {
|
||||||
flexibility_pct: 0,
|
flexibility_pct: 0,
|
||||||
health_pct: 0
|
health_pct: 0
|
||||||
})
|
})
|
||||||
const [goals, setGoals] = useState([])
|
const [goals, setGoals] = useState([]) // Kept for backward compat
|
||||||
|
const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals
|
||||||
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
|
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
|
||||||
const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup
|
const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup
|
||||||
const [showGoalForm, setShowGoalForm] = useState(false)
|
const [showGoalForm, setShowGoalForm] = useState(false)
|
||||||
|
|
@ -69,6 +48,8 @@ export default function GoalsPage() {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
goal_type: 'weight',
|
goal_type: 'weight',
|
||||||
is_primary: false,
|
is_primary: false,
|
||||||
|
category: 'body',
|
||||||
|
priority: 2,
|
||||||
target_value: '',
|
target_value: '',
|
||||||
unit: 'kg',
|
unit: 'kg',
|
||||||
target_date: '',
|
target_date: '',
|
||||||
|
|
@ -84,14 +65,16 @@ export default function GoalsPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [modeData, goalsData, typesData, focusData] = await Promise.all([
|
const [modeData, goalsData, groupedData, typesData, focusData] = await Promise.all([
|
||||||
api.getGoalMode(),
|
api.getGoalMode(),
|
||||||
api.listGoals(),
|
api.listGoals(),
|
||||||
|
api.listGoalsGrouped(), // v2.1: Load grouped by category
|
||||||
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB
|
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB
|
||||||
api.getFocusAreas() // v2.0: Load focus areas
|
api.getFocusAreas() // v2.0: Load focus areas
|
||||||
])
|
])
|
||||||
setGoalMode(modeData.goal_mode)
|
setGoalMode(modeData.goal_mode)
|
||||||
setGoals(goalsData)
|
setGoals(goalsData)
|
||||||
|
setGroupedGoals(groupedData)
|
||||||
|
|
||||||
// Ensure all focus fields are present and numeric
|
// Ensure all focus fields are present and numeric
|
||||||
const sanitizedFocus = {
|
const sanitizedFocus = {
|
||||||
|
|
@ -158,6 +141,8 @@ export default function GoalsPage() {
|
||||||
setFormData({
|
setFormData({
|
||||||
goal_type: firstType,
|
goal_type: firstType,
|
||||||
is_primary: goals.length === 0, // First goal is primary by default
|
is_primary: goals.length === 0, // First goal is primary by default
|
||||||
|
category: 'body',
|
||||||
|
priority: 2,
|
||||||
target_value: '',
|
target_value: '',
|
||||||
unit: goalTypesMap[firstType]?.unit || 'kg',
|
unit: goalTypesMap[firstType]?.unit || 'kg',
|
||||||
target_date: '',
|
target_date: '',
|
||||||
|
|
@ -172,6 +157,8 @@ export default function GoalsPage() {
|
||||||
setFormData({
|
setFormData({
|
||||||
goal_type: goal.goal_type,
|
goal_type: goal.goal_type,
|
||||||
is_primary: goal.is_primary,
|
is_primary: goal.is_primary,
|
||||||
|
category: goal.category || 'other',
|
||||||
|
priority: goal.priority || 2,
|
||||||
target_value: goal.target_value,
|
target_value: goal.target_value,
|
||||||
unit: goal.unit,
|
unit: goal.unit,
|
||||||
target_date: goal.target_date || '',
|
target_date: goal.target_date || '',
|
||||||
|
|
@ -199,6 +186,8 @@ export default function GoalsPage() {
|
||||||
const data = {
|
const data = {
|
||||||
goal_type: formData.goal_type,
|
goal_type: formData.goal_type,
|
||||||
is_primary: formData.is_primary,
|
is_primary: formData.is_primary,
|
||||||
|
category: formData.category,
|
||||||
|
priority: formData.priority,
|
||||||
target_value: parseFloat(formData.target_value),
|
target_value: parseFloat(formData.target_value),
|
||||||
unit: formData.unit,
|
unit: formData.unit,
|
||||||
target_date: formData.target_date || null,
|
target_date: formData.target_date || null,
|
||||||
|
|
@ -367,32 +356,6 @@ export default function GoalsPage() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weight Total Display */}
|
|
||||||
<div style={{
|
|
||||||
padding: 12,
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 16
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontWeight: 600, color: 'var(--text2)' }}>
|
|
||||||
Gewichtung gesamt:
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 18, fontWeight: 600, color: 'var(--text1)' }}>
|
|
||||||
{(() => {
|
|
||||||
const total = Object.entries(focusTemp)
|
|
||||||
.filter(([k]) => k.endsWith('_pct'))
|
|
||||||
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0)
|
|
||||||
return (total / 10).toFixed(1)
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, marginTop: 4, color: 'var(--text3)' }}>
|
|
||||||
💡 Die Prozentanteile werden automatisch berechnet
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -482,7 +445,7 @@ export default function GoalsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tactical Goals List */}
|
{/* Tactical Goals List - Category Grouped */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<h2 style={{ margin: 0 }}>🎯 Konkrete Ziele</h2>
|
<h2 style={{ margin: 0 }}>🎯 Konkrete Ziele</h2>
|
||||||
|
|
@ -491,7 +454,7 @@ export default function GoalsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{goals.length === 0 ? (
|
{Object.keys(groupedGoals).length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text2)' }}>
|
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text2)' }}>
|
||||||
<Target size={48} style={{ opacity: 0.3, marginBottom: 16 }} />
|
<Target size={48} style={{ opacity: 0.3, marginBottom: 16 }} />
|
||||||
<p>Noch keine Ziele definiert</p>
|
<p>Noch keine Ziele definiert</p>
|
||||||
|
|
@ -500,128 +463,150 @@ export default function GoalsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
|
||||||
{goals.map(goal => {
|
{Object.entries(GOAL_CATEGORIES).map(([catKey, catInfo]) => {
|
||||||
const typeInfo = goalTypesMap[goal.goal_type] || { label: goal.goal_type, unit: '', icon: '📊' }
|
const categoryGoals = groupedGoals[catKey] || []
|
||||||
|
if (categoryGoals.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={catKey}>
|
||||||
key={goal.id}
|
{/* Category Header */}
|
||||||
className="card"
|
<div style={{
|
||||||
style={{
|
display: 'flex',
|
||||||
background: 'var(--surface2)',
|
alignItems: 'center',
|
||||||
border: goal.is_primary ? '2px solid var(--accent)' : '1px solid var(--border)'
|
gap: 12,
|
||||||
}}
|
marginBottom: 16,
|
||||||
>
|
paddingBottom: 12,
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
borderBottom: `3px solid ${catInfo.color}`
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 28 }}>{catInfo.icon}</span>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
<h3 style={{ margin: 0, color: catInfo.color, fontSize: 18 }}>{catInfo.label}</h3>
|
||||||
<span style={{ fontSize: 20 }}>{typeInfo.icon}</span>
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text3)' }}>{catInfo.description}</p>
|
||||||
<span style={{ fontWeight: 600 }}>
|
</div>
|
||||||
{goal.name || typeInfo.label}
|
<span style={{
|
||||||
</span>
|
background: catInfo.color,
|
||||||
{goal.is_primary && (
|
color: 'white',
|
||||||
<span style={{
|
padding: '4px 12px',
|
||||||
background: 'var(--accent)',
|
borderRadius: 16,
|
||||||
color: 'white',
|
fontSize: 13,
|
||||||
fontSize: 11,
|
fontWeight: 600
|
||||||
padding: '2px 8px',
|
}}>
|
||||||
borderRadius: 4
|
{categoryGoals.length} {categoryGoals.length === 1 ? 'Ziel' : 'Ziele'}
|
||||||
}}>
|
</span>
|
||||||
PRIMÄR
|
</div>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span style={{
|
|
||||||
background: goal.status === 'active' ? '#E6F4F1' : '#F3F4F6',
|
|
||||||
color: goal.status === 'active' ? 'var(--accent)' : 'var(--text2)',
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4
|
|
||||||
}}>
|
|
||||||
{goal.status === 'active' ? 'AKTIV' : goal.status?.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 24, marginBottom: 12, fontSize: 14 }}>
|
{/* Goals in Category */}
|
||||||
<div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '}
|
{categoryGoals.map(goal => {
|
||||||
<strong>{goal.start_value} {goal.unit}</strong>
|
const typeInfo = goalTypesMap[goal.goal_type] || { label_de: goal.goal_type, unit: '', icon: '📊' }
|
||||||
</div>
|
const priorityInfo = PRIORITY_LEVELS[goal.priority] || PRIORITY_LEVELS[2]
|
||||||
<div>
|
|
||||||
<span style={{ color: 'var(--text2)' }}>Aktuell:</span>{' '}
|
|
||||||
<strong>{goal.current_value || '—'} {goal.unit}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style={{ color: 'var(--text2)' }}>Ziel:</span>{' '}
|
|
||||||
<strong>{goal.target_value} {goal.unit}</strong>
|
|
||||||
</div>
|
|
||||||
{goal.target_date && (
|
|
||||||
<div>
|
|
||||||
<Calendar size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
|
||||||
{dayjs(goal.target_date).format('DD.MM.YYYY')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{goal.progress_pct !== null && (
|
return (
|
||||||
<div>
|
<div
|
||||||
<div style={{
|
key={goal.id}
|
||||||
display: 'flex',
|
className="card"
|
||||||
justifyContent: 'space-between',
|
style={{
|
||||||
fontSize: 12,
|
background: 'var(--surface2)',
|
||||||
marginBottom: 4
|
border: `1px solid var(--border)`,
|
||||||
}}>
|
borderLeft: `5px solid ${catInfo.color}`
|
||||||
<span>Fortschritt</span>
|
}}
|
||||||
<span style={{ fontWeight: 600 }}>{goal.progress_pct}%</span>
|
>
|
||||||
</div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
|
||||||
<div style={{
|
<div style={{ flex: 1 }}>
|
||||||
width: '100%',
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
height: 6,
|
<span style={{ fontSize: 20 }}>{typeInfo.icon}</span>
|
||||||
background: 'var(--surface)',
|
<span style={{ fontWeight: 600, fontSize: 16 }}>
|
||||||
borderRadius: 3,
|
{goal.name || typeInfo.label_de}
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: `${Math.min(100, Math.max(0, goal.progress_pct))}%`,
|
|
||||||
height: '100%',
|
|
||||||
background: getProgressColor(goal.progress_pct),
|
|
||||||
transition: 'width 0.3s ease'
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{goal.on_track !== null && (
|
|
||||||
<div style={{ marginTop: 8, fontSize: 12 }}>
|
|
||||||
{goal.on_track ? (
|
|
||||||
<span style={{ color: 'var(--accent)' }}>
|
|
||||||
✓ Ziel voraussichtlich erreichbar bis {dayjs(goal.target_date).format('DD.MM.YYYY')}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
<span style={{ fontSize: 16 }} title={priorityInfo.label}>
|
||||||
<span style={{ color: '#D85A30' }}>
|
{priorityInfo.stars}
|
||||||
⚠ Prognose: {goal.projection_date ? dayjs(goal.projection_date).format('DD.MM.YYYY') : 'Offen'}
|
|
||||||
{goal.target_date && ' (später als geplant)'}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{
|
||||||
|
background: goal.status === 'active' ? '#E6F4F1' : '#F3F4F6',
|
||||||
|
color: goal.status === 'active' ? 'var(--accent)' : 'var(--text2)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontWeight: 500
|
||||||
|
}}>
|
||||||
|
{goal.status === 'active' ? 'AKTIV' : goal.status?.toUpperCase() || 'AKTIV'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 20, marginBottom: 12, fontSize: 14, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '}
|
||||||
|
<strong>{goal.start_value} {goal.unit}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text2)' }}>Aktuell:</span>{' '}
|
||||||
|
<strong>{goal.current_value || '—'} {goal.unit}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text2)' }}>Ziel:</span>{' '}
|
||||||
|
<strong style={{ color: catInfo.color }}>{goal.target_value} {goal.unit}</strong>
|
||||||
|
</div>
|
||||||
|
{goal.target_date && (
|
||||||
|
<div style={{ color: 'var(--text2)' }}>
|
||||||
|
<Calendar size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{dayjs(goal.target_date).format('DD.MM.YYYY')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{goal.progress_pct !== null && (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: 'var(--text2)'
|
||||||
|
}}>
|
||||||
|
<span>Fortschritt</span>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text1)' }}>{goal.progress_pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 8,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${Math.min(100, Math.max(0, goal.progress_pct))}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: catInfo.color,
|
||||||
|
transition: 'width 0.3s ease'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
className="btn-secondary"
|
className="btn-secondary"
|
||||||
onClick={() => handleEditGoal(goal)}
|
onClick={() => handleEditGoal(goal)}
|
||||||
style={{ padding: '6px 12px' }}
|
style={{ padding: '6px 12px' }}
|
||||||
>
|
title="Bearbeiten"
|
||||||
<Pencil size={14} />
|
>
|
||||||
</button>
|
<Pencil size={14} />
|
||||||
<button
|
</button>
|
||||||
className="btn-secondary"
|
<button
|
||||||
onClick={() => handleDeleteGoal(goal.id)}
|
className="btn-secondary"
|
||||||
style={{ padding: '6px 12px', color: '#DC2626' }}
|
onClick={() => handleDeleteGoal(goal.id)}
|
||||||
>
|
style={{ padding: '6px 12px', color: '#DC2626' }}
|
||||||
<Trash2 size={14} />
|
title="Löschen"
|
||||||
</button>
|
>
|
||||||
</div>
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -817,34 +802,61 @@ export default function GoalsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primärziel */}
|
{/* Category & Priority */}
|
||||||
<div style={{
|
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, marginTop: 20, color: 'var(--text1)' }}>
|
||||||
padding: 12,
|
📂 Kategorisierung
|
||||||
background: 'var(--surface2)',
|
</div>
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 20
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||||
}}>
|
{/* Category */}
|
||||||
<label style={{
|
<div style={{ flex: 1 }}>
|
||||||
display: 'flex',
|
<label style={{
|
||||||
alignItems: 'flex-start',
|
display: 'block',
|
||||||
gap: 10,
|
fontSize: 13,
|
||||||
cursor: 'pointer'
|
fontWeight: 500,
|
||||||
}}>
|
marginBottom: 4,
|
||||||
<input
|
color: 'var(--text2)'
|
||||||
type="checkbox"
|
}}>
|
||||||
checked={formData.is_primary}
|
Kategorie *
|
||||||
onChange={e => setFormData(f => ({ ...f, is_primary: e.target.checked }))}
|
</label>
|
||||||
style={{ marginTop: 2 }}
|
<select
|
||||||
/>
|
className="form-input"
|
||||||
<div>
|
style={{ width: '100%' }}
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>
|
value={formData.category}
|
||||||
Als Primärziel setzen
|
onChange={e => setFormData(f => ({ ...f, category: e.target.value }))}
|
||||||
</div>
|
>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text2)' }}>
|
{Object.entries(GOAL_CATEGORIES).map(([key, info]) => (
|
||||||
Dein Primärziel hat höchste Priorität in Analysen und Charts
|
<option key={key} value={key}>
|
||||||
</div>
|
{info.icon} {info.label}
|
||||||
</div>
|
</option>
|
||||||
</label>
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: 'var(--text2)'
|
||||||
|
}}>
|
||||||
|
Priorität *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={e => setFormData(f => ({ ...f, priority: parseInt(e.target.value) }))}
|
||||||
|
>
|
||||||
|
{Object.entries(PRIORITY_LEVELS).map(([level, info]) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{info.stars} {info.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,7 @@ export const api = {
|
||||||
updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)),
|
updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)),
|
||||||
|
|
||||||
listGoals: () => req('/goals/list'),
|
listGoals: () => req('/goals/list'),
|
||||||
|
listGoalsGrouped: () => req('/goals/grouped'),
|
||||||
createGoal: (d) => req('/goals/create', json(d)),
|
createGoal: (d) => req('/goals/create', json(d)),
|
||||||
updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),
|
updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),
|
||||||
deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}),
|
deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user