- Add get_training_type_for_apple_health() mapping function (23 workout types) - CSV import now automatically assigns training_type_id/category/subcategory - New endpoint: GET /activity/uncategorized (grouped by activity_type) - New endpoint: POST /activity/bulk-categorize (bulk update training types) - New component: BulkCategorize with two-level dropdown selection - ActivityPage: new "Kategorisieren" tab for existing activities - Update CLAUDE.md: v9d Phase 1b progress Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
192 lines
5.6 KiB
JavaScript
192 lines
5.6 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { api } from '../utils/api'
|
|
import TrainingTypeSelect from './TrainingTypeSelect'
|
|
|
|
/**
|
|
* BulkCategorize - UI for categorizing existing activities without training type
|
|
*
|
|
* Shows uncategorized activities grouped by activity_type,
|
|
* allows bulk assignment of training type to all activities of same type.
|
|
*/
|
|
export default function BulkCategorize({ onComplete }) {
|
|
const [uncategorized, setUncategorized] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [assignments, setAssignments] = useState({})
|
|
const [saving, setSaving] = useState(null)
|
|
|
|
useEffect(() => {
|
|
loadUncategorized()
|
|
}, [])
|
|
|
|
const loadUncategorized = () => {
|
|
setLoading(true)
|
|
api.listUncategorizedActivities()
|
|
.then(data => {
|
|
setUncategorized(data)
|
|
setLoading(false)
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load uncategorized activities:', err)
|
|
setLoading(false)
|
|
})
|
|
}
|
|
|
|
const handleAssignment = (activityType, typeId, category, subcategory) => {
|
|
setAssignments(prev => ({
|
|
...prev,
|
|
[activityType]: {
|
|
training_type_id: typeId,
|
|
training_category: category,
|
|
training_subcategory: subcategory
|
|
}
|
|
}))
|
|
}
|
|
|
|
const handleSave = async (activityType) => {
|
|
const assignment = assignments[activityType]
|
|
if (!assignment || !assignment.training_type_id) {
|
|
alert('Bitte wähle einen Trainingstyp aus')
|
|
return
|
|
}
|
|
|
|
setSaving(activityType)
|
|
try {
|
|
const result = await api.bulkCategorizeActivities({
|
|
activity_type: activityType,
|
|
...assignment
|
|
})
|
|
|
|
// Remove from list
|
|
setUncategorized(prev => prev.filter(u => u.activity_type !== activityType))
|
|
setAssignments(prev => {
|
|
const newAssignments = { ...prev }
|
|
delete newAssignments[activityType]
|
|
return newAssignments
|
|
})
|
|
|
|
// Show success message
|
|
console.log(`✓ ${result.updated} activities categorized`)
|
|
|
|
} catch (err) {
|
|
console.error('Failed to categorize:', err)
|
|
alert('Kategorisierung fehlgeschlagen: ' + err.message)
|
|
} finally {
|
|
setSaving(null)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
|
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto' }} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (uncategorized.length === 0) {
|
|
return (
|
|
<div style={{
|
|
textAlign: 'center',
|
|
padding: 40,
|
|
color: 'var(--text3)',
|
|
fontSize: 14
|
|
}}>
|
|
<div style={{ fontSize: 32, marginBottom: 8 }}>✓</div>
|
|
<div>Alle Aktivitäten sind kategorisiert</div>
|
|
{onComplete && (
|
|
<button
|
|
onClick={onComplete}
|
|
className="btn btn-secondary"
|
|
style={{ marginTop: 16 }}
|
|
>
|
|
Schließen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
|
<div style={{
|
|
marginBottom: 20,
|
|
padding: 16,
|
|
background: 'var(--surface)',
|
|
borderRadius: 8,
|
|
fontSize: 13,
|
|
color: 'var(--text2)'
|
|
}}>
|
|
<strong style={{ color: 'var(--text1)' }}>
|
|
{uncategorized.reduce((sum, u) => sum + u.count, 0)} Aktivitäten
|
|
</strong> ohne Trainingstyp gefunden. Weise jedem Aktivitätstyp einen Trainingstyp zu.
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{uncategorized.map(item => (
|
|
<div
|
|
key={item.activity_type}
|
|
className="card"
|
|
style={{ padding: 16 }}
|
|
>
|
|
<div style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'flex-start',
|
|
marginBottom: 12
|
|
}}>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
|
|
{item.activity_type}
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
|
{item.count} Einheiten
|
|
{item.first_date && item.last_date && (
|
|
<> · {item.first_date} bis {item.last_date}</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<TrainingTypeSelect
|
|
value={assignments[item.activity_type]?.training_type_id || null}
|
|
onChange={(typeId, category, subcategory) =>
|
|
handleAssignment(item.activity_type, typeId, category, subcategory)
|
|
}
|
|
required={false}
|
|
/>
|
|
|
|
<button
|
|
onClick={() => handleSave(item.activity_type)}
|
|
disabled={
|
|
!assignments[item.activity_type]?.training_type_id ||
|
|
saving === item.activity_type
|
|
}
|
|
className="btn btn-primary btn-full"
|
|
style={{ marginTop: 12 }}
|
|
>
|
|
{saving === item.activity_type ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'center' }}>
|
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
|
Speichere...
|
|
</div>
|
|
) : (
|
|
`${item.count} Einheiten kategorisieren`
|
|
)}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{onComplete && (
|
|
<button
|
|
onClick={onComplete}
|
|
className="btn btn-secondary btn-full"
|
|
style={{ marginTop: 16 }}
|
|
>
|
|
Später fortsetzen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|