shinkan-jinkendo/frontend/src/components/DashboardTrainingVisibilityWidget.jsx
Lars 16eaf839e7
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 19s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m21s
Enhance frontend testing setup and refactor TrainingPlanningPageRoot component
- Added Vitest as a testing framework and included test scripts in package.json for improved testing capabilities.
- Refactored TrainingPlanningPageRoot component by removing unused state variables and imports, streamlining the code for better readability and performance.
- Introduced new utility functions for planning routes to enhance navigation within the training planning interface.
2026-05-19 11:02:03 +02:00

348 lines
12 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { CalendarDays, ClipboardList } from 'lucide-react'
import api from '../utils/api'
const VIS_LABELS = { private: 'Privat', club: 'Verein', official: 'Offiziell' }
const STATUS_LABELS = {
draft: 'Entwurf',
in_review: 'Prüfung',
approved: 'Freigegeben',
archived: 'Archiv',
}
function rowKey(item) {
return `${item.exercise_id}-${item.target_club_id}`
}
function unitPlanTitle(u) {
const d = (u.planned_date || '').toString().slice(0, 10)
const g = (u.group_name || '').trim()
return [d, g].filter(Boolean).join(' · ') || `Einheit #${u.id}`
}
/**
* Dashboard: Übungen aus eigenen Trainingseinheiten, die für den Verein der Gruppe noch nicht freigegeben sind.
*/
export default function DashboardTrainingVisibilityWidget({ user }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [err, setErr] = useState(null)
const [selected, setSelected] = useState(() => new Set())
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const load = useCallback(async () => {
if (!user?.id) return
setErr(null)
setLoading(true)
try {
const res = await api.getTrainingExerciseClubVisibilityQueue({ limit_units: 100 })
const list = Array.isArray(res?.items) ? res.items : []
setItems(list)
setSelected(new Set())
setMsg(null)
} catch (e) {
setErr(e?.message || String(e))
setItems([])
} finally {
setLoading(false)
}
}, [user?.id])
useEffect(() => {
load()
}, [load])
const promotableSelected = useMemo(() => {
const out = []
for (const k of selected) {
const it = items.find((x) => rowKey(x) === k)
if (it?.can_promote) out.push(it)
}
return out
}, [items, selected])
const toggle = (key) => {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(key)) n.delete(key)
else n.add(key)
return n
})
}
const toggleAllPromotable = () => {
const keys = items.filter((i) => i.can_promote).map(rowKey)
setSelected((prev) => {
if (keys.length && keys.every((k) => prev.has(k))) {
return new Set()
}
return new Set(keys)
})
}
const promoteSelected = async () => {
if (!promotableSelected.length) return
setBusy(true)
setMsg(null)
try {
const byClub = new Map()
for (const it of promotableSelected) {
const cid = it.target_club_id
if (!byClub.has(cid)) byClub.set(cid, [])
byClub.get(cid).push(it.exercise_id)
}
let anyFail = false
for (const [clubId, ids] of byClub) {
const uniq = [...new Set(ids)]
const res = await api.bulkPatchExercisesMetadata({
exercise_ids: uniq,
visibility: 'club',
club_id: clubId,
})
if ((res?.failed || []).length) {
anyFail = true
const f = res.failed[0]
setMsg(f?.detail || 'Freigabe teilweise fehlgeschlagen')
}
}
if (!anyFail) setMsg(null)
await load()
} catch (e) {
setMsg(e?.message || String(e))
} finally {
setBusy(false)
}
}
if (!user?.id) return null
if (loading) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }}>
<p className="muted" style={{ margin: 0 }}>
Vereinsfreigaben werden geladen
</p>
</div>
)
}
if (err) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }} role="alert">
<p style={{ margin: 0, color: 'var(--danger, #c0392b)' }}>{err}</p>
</div>
)
}
if (!items.length) {
return (
<div className="card dashboard-preview-card" style={{ marginTop: '1rem' }}>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Vereinssichtbarkeit in deinen Trainings
</h3>
<p className="dashboard-preview-card__empty" style={{ margin: 0 }}>
Keine Übungen in den abgefragten Einheiten, die noch auf Verein gestellt werden müssten.
</p>
</div>
)
}
const allPromo = items.filter((i) => i.can_promote)
const allSelected = allPromo.length > 0 && allPromo.every((i) => selected.has(rowKey(i)))
return (
<div className="card dashboard-preview-card dashboard-vis-queue" style={{ marginTop: '1rem' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div>
<h3 className="dashboard-preview-card__title" style={{ marginTop: 0 }}>
Vereinssichtbarkeit in deinen Trainings
</h3>
<p
className="muted"
style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', lineHeight: 1.45, maxWidth: '52rem' }}
>
Übungen in deinen Einheiten, die für den jeweiligen Verein noch nicht sichtbar sind auf{' '}
<strong>Verein</strong> setzen oder zur Bearbeitung / Planung springen.
</p>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
{allPromo.length ? (
<button type="button" className="btn btn-secondary" onClick={toggleAllPromotable}>
{allSelected ? 'Auswahl leeren' : 'Alle wählbar'}
</button>
) : null}
<button
type="button"
className="btn btn-primary"
disabled={!promotableSelected.length || busy}
onClick={promoteSelected}
>
{busy ? 'Speichern…' : `Freigeben (${promotableSelected.length})`}
</button>
</div>
</div>
<div style={{ marginTop: '0.75rem', overflowX: 'auto' }}>
<table
className="dashboard-vis-queue__table"
style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}
>
<thead>
<tr
style={{
textAlign: 'left',
borderBottom: '1px solid var(--border2)',
color: 'var(--text3)',
fontSize: '0.78rem',
}}
>
<th style={{ width: 32, padding: '6px 4px' }} aria-label="Auswahl" />
<th style={{ padding: '6px 6px' }}>Übung</th>
<th style={{ padding: '6px 6px', whiteSpace: 'nowrap' }}>Sichtbarkeit</th>
<th style={{ padding: '6px 6px', whiteSpace: 'nowrap' }}>Status</th>
<th style={{ padding: '6px 6px' }}>Verein</th>
<th style={{ padding: '6px 4px', width: 76 }} title="Planung und Durchführung">
Kontext
</th>
</tr>
</thead>
<tbody>
{items.map((it) => {
const k = rowKey(it)
const on = selected.has(k)
const visL = VIS_LABELS[it.visibility] || it.visibility
const stL = STATUS_LABELS[it.status] || it.status
const first = it.units && it.units[0]
const restN = (it.units?.length || 0) - 1
const tool = (it.units || []).map(unitPlanTitle).join('\n')
const cn = (it.target_club_name || '').trim()
return (
<tr
key={k}
style={{ borderBottom: '1px solid var(--border2)' }}
title={restN > 0 ? tool : undefined}
>
<td style={{ padding: '6px 4px', verticalAlign: 'top' }}>
<input
type="checkbox"
checked={on}
disabled={!it.can_promote}
onChange={() => toggle(k)}
title={it.can_promote ? 'Zur Freigabe wählen' : 'Keine Berechtigung für diese Freigabe'}
aria-label={`${it.title} auswählen`}
/>
</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', minWidth: 140 }}>
<Link
to={`/exercises/${it.exercise_id}/edit`}
className="dashboard-preview-card__link"
style={{ fontWeight: 600 }}
>
{it.title}
</Link>
</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>{visL}</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>{stL}</td>
<td style={{ padding: '6px 6px', verticalAlign: 'top', maxWidth: 200 }}>
<span title={cn}>{cn.length > 28 ? `${cn.slice(0, 28)}` : cn || '—'}</span>
</td>
<td style={{ padding: '6px 4px', verticalAlign: 'top' }}>
{first ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
<Link
to={`/planning/units/${first.id}/edit`}
className="btn-ghost"
style={{
padding: '2px 4px',
display: 'inline-flex',
borderRadius: 6,
textDecoration: 'none',
color: 'var(--text1)',
}}
title={
restN > 0
? `Planung (${unitPlanTitle(first)}; +${restN} weitere — siehe Tooltip ganze Zeile)`
: `Planung öffnen: ${unitPlanTitle(first)}`
}
aria-label="Planung öffnen"
>
<CalendarDays size={16} strokeWidth={2} aria-hidden />
</Link>
<Link
to={`/planning/run/${first.id}`}
className="btn-ghost"
style={{
padding: '2px 4px',
display: 'inline-flex',
borderRadius: 6,
textDecoration: 'none',
color: 'var(--text1)',
}}
title="Durchführung / Ablauf"
aria-label="Durchführung öffnen"
>
<ClipboardList size={16} strokeWidth={2} aria-hidden />
</Link>
{restN > 0 ? (
<span className="muted" style={{ fontSize: '0.72rem' }} title={tool}>
+{restN}
</span>
) : null}
</div>
) : (
'—'
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{items.some((i) => !i.can_promote) ? (
<p className="muted" style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', lineHeight: 1.45 }}>
Ausgegraute Kästchen: keine direkte Freigabe-Berechtigung Vereinsorga kontaktieren oder die Einheit in
der Planung speichern (dann ggf. automatische Vereinsfreigabe).
</p>
) : null}
<p className="muted" style={{ margin: '0.5rem 0 0', fontSize: '0.8rem', lineHeight: 1.45 }}>
Mehrere Termine: Kalender-Icon nutzt den frühesten; +N listet alle Daten und Gruppen im Tooltip.
</p>
{msg ? (
<p
style={{ margin: '0.65rem 0 0', fontSize: '0.84rem', color: 'var(--danger, #c0392b)' }}
role="alert"
>
{msg}
</p>
) : null}
<p style={{ margin: '0.65rem 0 0', fontSize: '0.82rem' }}>
<Link to="/planning">Zur Trainingsplanung</Link>
<span className="muted"> · Zeitraum ca. 45 Tage zurück bis 1 Jahr voraus; bis zu 100 Einheiten.</span>
</p>
</div>
)
}