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>
This commit is contained in:
parent
08cead49fe
commit
96b0acacd2
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -95,17 +95,23 @@ frontend/src/
|
||||||
|
|
||||||
### Auf develop (nicht deployed) 📦
|
### Auf develop (nicht deployed) 📦
|
||||||
- ✅ **TrialBanner mailto:** "Abo wählen" → mailto:mitai@jinkendo.de (Vorbereitung für zentrales Abo-System)
|
- ✅ **TrialBanner mailto:** "Abo wählen" → mailto:mitai@jinkendo.de (Vorbereitung für zentrales Abo-System)
|
||||||
|
- ✅ **Apple Health Mapping:** Automatische Trainingstyp-Zuordnung beim CSV-Import (23 Workout-Typen)
|
||||||
|
- ✅ **Bulk-Kategorisierung:** Nachträgliche Typ-Zuweisung für bestehende Aktivitäten
|
||||||
|
- ✅ **ActivityPage Integration:** TrainingTypeSelect + "Kategorisieren"-Tab
|
||||||
|
- ✅ **Dashboard Integration:** TrainingTypeDistribution Chart (28 Tage)
|
||||||
- 📚 Dokumentation: `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
|
- 📚 Dokumentation: `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
|
||||||
|
|
||||||
### v9d – Phase 1 ✅ (Deployed 21.03.2026)
|
### v9d – Phase 1 ✅ (Deployed 21.03.2026)
|
||||||
- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints
|
- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints
|
||||||
- ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung
|
- ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung
|
||||||
- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution (noch nicht eingebunden)
|
- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution
|
||||||
|
|
||||||
### v9d – Phase 1b 🔲 (Integration)
|
### v9d – Phase 1b ⏳ (In Progress)
|
||||||
- 🔲 ActivityPage: TrainingTypeSelect einbinden
|
- ✅ ActivityPage: TrainingTypeSelect eingebunden
|
||||||
- 🔲 Dashboard: TrainingTypeDistribution Chart
|
- ✅ Dashboard: TrainingTypeDistribution Chart eingebunden
|
||||||
- 🔲 History: Typ-Badge bei Aktivitäten
|
- ✅ Apple Health Import: Automatisches Mapping
|
||||||
|
- ✅ Bulk-Kategorisierung: UI + Endpoints
|
||||||
|
- 🔲 History: Typ-Badge bei Aktivitäten (ausstehend)
|
||||||
|
|
||||||
### v9d – Phase 2+ 🔲 (Später)
|
### v9d – Phase 2+ 🔲 (Später)
|
||||||
- 🔲 Ruhetage erfassen (rest_days Tabelle)
|
- 🔲 Ruhetage erfassen (rest_days Tabelle)
|
||||||
|
|
|
||||||
|
|
@ -113,9 +113,113 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di
|
||||||
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
||||||
|
|
||||||
|
|
||||||
|
def get_training_type_for_apple_health(workout_type: str):
|
||||||
|
"""
|
||||||
|
Map Apple Health workout type to training_type_id + category + subcategory.
|
||||||
|
Returns: (training_type_id, category, subcategory) or (None, None, None)
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Mapping: Apple Health Workout Type → training_type subcategory
|
||||||
|
mapping = {
|
||||||
|
'running': 'running',
|
||||||
|
'walking': 'walk',
|
||||||
|
'hiking': 'walk',
|
||||||
|
'cycling': 'cycling',
|
||||||
|
'swimming': 'swimming',
|
||||||
|
'traditional strength training': 'hypertrophy',
|
||||||
|
'functional strength training': 'functional',
|
||||||
|
'high intensity interval training': 'hiit',
|
||||||
|
'yoga': 'yoga',
|
||||||
|
'martial arts': 'technique',
|
||||||
|
'boxing': 'sparring',
|
||||||
|
'rowing': 'rowing',
|
||||||
|
'dance': 'other',
|
||||||
|
'core training': 'functional',
|
||||||
|
'flexibility': 'static',
|
||||||
|
'cooldown': 'regeneration',
|
||||||
|
}
|
||||||
|
|
||||||
|
subcategory = mapping.get(workout_type.lower())
|
||||||
|
if not subcategory:
|
||||||
|
return (None, None, None)
|
||||||
|
|
||||||
|
# Find training_type_id by subcategory
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, category, subcategory
|
||||||
|
FROM training_types
|
||||||
|
WHERE LOWER(subcategory) = %s
|
||||||
|
LIMIT 1
|
||||||
|
""", (subcategory,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return (row['id'], row['category'], row['subcategory'])
|
||||||
|
|
||||||
|
return (None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/uncategorized")
|
||||||
|
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
|
"""Get activities without assigned training type, grouped by activity_type."""
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT activity_type, COUNT(*) as count,
|
||||||
|
MIN(date) as first_date, MAX(date) as last_date
|
||||||
|
FROM activity_log
|
||||||
|
WHERE profile_id=%s AND training_type_id IS NULL
|
||||||
|
GROUP BY activity_type
|
||||||
|
ORDER BY count DESC
|
||||||
|
""", (pid,))
|
||||||
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-categorize")
|
||||||
|
def bulk_categorize_activities(
|
||||||
|
data: dict,
|
||||||
|
x_profile_id: Optional[str]=Header(default=None),
|
||||||
|
session: dict=Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Bulk update training type for activities.
|
||||||
|
Body: {
|
||||||
|
"activity_type": "Running",
|
||||||
|
"training_type_id": 1,
|
||||||
|
"training_category": "cardio",
|
||||||
|
"training_subcategory": "running"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
activity_type = data.get('activity_type')
|
||||||
|
training_type_id = data.get('training_type_id')
|
||||||
|
training_category = data.get('training_category')
|
||||||
|
training_subcategory = data.get('training_subcategory')
|
||||||
|
|
||||||
|
if not activity_type or not training_type_id:
|
||||||
|
raise HTTPException(400, "activity_type and training_type_id required")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE activity_log
|
||||||
|
SET training_type_id = %s,
|
||||||
|
training_category = %s,
|
||||||
|
training_subcategory = %s
|
||||||
|
WHERE profile_id = %s
|
||||||
|
AND activity_type = %s
|
||||||
|
AND training_type_id IS NULL
|
||||||
|
""", (training_type_id, training_category, training_subcategory, pid, activity_type))
|
||||||
|
updated_count = cur.rowcount
|
||||||
|
|
||||||
|
return {"updated": updated_count, "activity_type": activity_type}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-csv")
|
@router.post("/import-csv")
|
||||||
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Import Apple Health workout CSV."""
|
"""Import Apple Health workout CSV with automatic training type mapping."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
|
|
@ -145,16 +249,20 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
def tf(v):
|
def tf(v):
|
||||||
try: return round(float(v),1) if v else None
|
try: return round(float(v),1) if v else None
|
||||||
except: return None
|
except: return None
|
||||||
|
# Map Apple Health workout type to training_type_id
|
||||||
|
training_type_id, training_category, training_subcategory = get_training_type_for_apple_health(wtype)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cur.execute("""INSERT INTO activity_log
|
cur.execute("""INSERT INTO activity_log
|
||||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||||
hr_avg,hr_max,distance_km,source,created)
|
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""",
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||||
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
|
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
|
||||||
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
||||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||||
tf(row.get('Distanz (km)',''))))
|
tf(row.get('Distanz (km)','')),
|
||||||
|
training_type_id,training_category,training_subcategory))
|
||||||
inserted+=1
|
inserted+=1
|
||||||
except: skipped+=1
|
except: skipped+=1
|
||||||
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
||||||
|
|
|
||||||
191
frontend/src/components/BulkCategorize.jsx
Normal file
191
frontend/src/components/BulkCategorize.jsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGri
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import UsageBadge from '../components/UsageBadge'
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import TrainingTypeSelect from '../components/TrainingTypeSelect'
|
import TrainingTypeSelect from '../components/TrainingTypeSelect'
|
||||||
|
import BulkCategorize from '../components/BulkCategorize'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -258,6 +259,7 @@ export default function ActivityPage() {
|
||||||
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
|
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
|
||||||
<button className={'tab'+(tab==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button>
|
<button className={'tab'+(tab==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button>
|
||||||
<button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</button>
|
<button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</button>
|
||||||
|
<button className={'tab'+(tab==='categorize'?' active':'')} onClick={()=>setTab('categorize')}>Kategorisieren</button>
|
||||||
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
|
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -279,6 +281,13 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{tab==='import' && <ImportPanel onImported={load}/>}
|
{tab==='import' && <ImportPanel onImported={load}/>}
|
||||||
|
|
||||||
|
{tab==='categorize' && (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">🏷️ Aktivitäten kategorisieren</div>
|
||||||
|
<BulkCategorize onComplete={() => { load(); setTab('list'); }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab==='add' && (
|
{tab==='add' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title badge-container-right">
|
<div className="card-title badge-container-right">
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ export const api = {
|
||||||
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
||||||
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
||||||
activityStats: () => req('/activity/stats'),
|
activityStats: () => req('/activity/stats'),
|
||||||
|
listUncategorizedActivities: () => req('/activity/uncategorized'),
|
||||||
|
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
|
||||||
importActivityCsv: async(file)=>{
|
importActivityCsv: async(file)=>{
|
||||||
const fd=new FormData();fd.append('file',file)
|
const fd=new FormData();fd.append('file',file)
|
||||||
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user