mitai-jinkendo/frontend/src/components/BulkCategorize.jsx
Lars 96b0acacd2
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: automatic training type mapping for Apple Health import and bulk categorization
- 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>
2026-03-21 15:08:18 +01:00

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