trainlog/web/src/routes/SessionsPage.tsx

734 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from 'react'
import {
fetchCatalog,
deleteSessionResource,
fetchSessionDetail,
fetchReadonlySessionDetail,
prepareForAndroid,
fetchAllSessionPages,
savePreparation,
withdrawPreparation,
type CatalogChoice,
type PreparationInput,
type PreparationOccurrence,
type SessionCollection,
type SessionDetail,
type SessionListItem,
} from '../api/sessions'
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
import { useDatePreferences } from '../presentation/DatePreferences'
import {
formatCivilDate,
formatDateTime,
validDateSortValue,
validTimestampValue,
} from '../presentation/dateFormat'
import { ProgramsTab } from './ProgramsTab'
type View = 'preparation' | 'resume' | 'history' | 'programs'
const collections: Record<Exclude<View, 'programs'>, SessionCollection[]> = {
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
}
function viewLabel(view: View): string {
const labels: Record<View, string> = {
preparation: 'Préparation',
resume: 'À reprendre',
history: 'Historique',
programs: 'Programmes',
}
return labels[view]
}
export type SessionListEntry = SessionListItem & { collection: SessionCollection }
function deletionKind(collection: SessionCollection): 'preparation' | 'proposal' | 'draft' | 'history' {
if (collection === 'preparations') return 'preparation'
if (collection === 'proposals') return 'proposal'
if (collection === 'drafts') return 'draft'
return 'history'
}
function deletionLabel(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
const labels = {
preparation: 'Supprimer la préparation',
proposal: 'Supprimer la proposition',
draft: 'Abandonner le brouillon',
history: 'Supprimer la séance de l’historique',
}
return labels[kind]
}
function deletionType(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
const labels = {
preparation: 'PRÉPARATION MANUELLE',
proposal: 'PROPOSITION IA',
draft: 'BROUILLON D’EXÉCUTION',
history: 'SÉANCE RÉALISÉE',
}
return labels[kind]
}
function deletionConsequence(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
const consequences = {
preparation: 'La préparation sera retirée. Ses livraisons Android non commencées seront annulées, sans effacer un entraînement réel.',
proposal: 'La proposition sera retirée et son retrait sera propagé. Les préparations ou séances déjà dérivées seront conservées.',
draft: 'Le brouillon sera abandonné seulement s’il n’est pas actif ou modifié concurremment sur Android.',
history: 'La séance sera retirée de l’historique avec une protection empêchant sa réapparition depuis un ancien instantané.',
}
return consequences[kind]
}
function deletionSuccess(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
const messages = {
preparation: 'Préparation supprimée.',
proposal: 'Proposition supprimée.',
draft: 'Brouillon abandonné.',
history: 'Séance supprimée de l’historique.',
}
return messages[kind]
}
function compareIdentity(left: SessionListEntry, right: SessionListEntry): number {
return `${left.collection}:${left.identity}`.localeCompare(
`${right.collection}:${right.identity}`, 'en')
}
function compareTimestampDescending(left: string, right: string): number {
const leftTimestamp = validTimestampValue(left)
const rightTimestamp = validTimestampValue(right)
if (leftTimestamp === null && rightTimestamp === null) return 0
if (leftTimestamp === null) return 1
if (rightTimestamp === null) return -1
return rightTimestamp - leftTimestamp
}
export function sortSessionItems(items: readonly SessionListEntry[], view: View): SessionListEntry[] {
return [...items].sort((left, right) => {
if (view === 'preparation') {
const leftDate = validDateSortValue(left.date)
const rightDate = validDateSortValue(right.date)
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
return rightDate.localeCompare(leftDate, 'en')
}
if (leftDate === null && rightDate !== null) return 1
if (leftDate !== null && rightDate === null) return -1
}
const timestampOrder = compareTimestampDescending(left.sort_timestamp, right.sort_timestamp)
return timestampOrder !== 0 ? timestampOrder : compareIdentity(left, right)
})
}
export function sessionStateLabel(collection: SessionCollection, state: string): string {
const labels: Record<SessionCollection, Record<string, string>> = {
preparations: {
draft: 'En préparation', ready: 'Prête', local: 'Locale', pending: 'À synchroniser',
acknowledged: 'Reçue', remote_unknown: 'État Android inconnu', withdrawn: 'Supprimée',
},
proposals: { local: 'À valider', pending: 'À valider', published: 'Publiée' },
drafts: { active: 'En cours', pending: 'À reprendre', stale: 'À vérifier' },
history: { completed: 'Terminée' },
}
return labels[collection][state] ?? `État non reconnu (${state})`
}
function parseDecimal(value: string): number | null {
if (!value.trim()) return null
const normalized = value.replace(',', '.')
const number = Number(normalized)
return Number.isFinite(number) ? number : null
}
function emptyInput(): PreparationInput {
return { title: '', session_type: 'training', planned_for: null, notes: null,
editing_state: 'draft', occurrences: [] }
}
function editableOccurrence(
value: PreparationOccurrence,
preserveIdentity: boolean,
): PreparationOccurrence {
return {
...(preserveIdentity ? { entry_id: value.entry_id } : {}),
exercise_id: value.exercise_id,
exercise_name: value.exercise_name,
equipment_id: value.equipment_id,
recording_mode: value.recording_mode,
tracking_mode: value.tracking_mode,
load_mode: value.load_mode,
rest_seconds: value.rest_seconds,
target_sets: value.target_sets,
target_reps: value.target_reps,
target_duration_seconds: value.target_duration_seconds,
target_weight_kg: value.target_weight_kg,
notes: value.notes,
}
}
export function commandOccurrence(value: PreparationOccurrence): PreparationOccurrence {
return {
...(value.entry_id ? { entry_id: value.entry_id } : {}),
exercise_id: value.exercise_id,
equipment_id: value.equipment_id,
load_mode: value.load_mode,
rest_seconds: value.rest_seconds,
target_sets: value.target_sets,
target_reps: value.target_reps,
target_duration_seconds: value.target_duration_seconds,
target_weight_kg: value.target_weight_kg,
notes: value.notes,
}
}
function Editor({ initial, onSaved, onCancel }: {
initial?: SessionDetail
onSaved: (identity: string) => void
onCancel: () => void
}) {
const [input, setInput] = useState<PreparationInput>(() => initial ? {
title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for,
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) =>
editableOccurrence(value, initial.kind === 'preparation')),
...(initial.kind === 'proposal' ? {
source_proposal_id: initial.identity,
source_payload_sha256: initial.source_fingerprint ?? undefined,
} : {}),
} : emptyInput())
const [catalog, setCatalog] = useState<CatalogChoice[]>([])
const [catalogSearch, setCatalogSearch] = useState('')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
const controller = new AbortController()
fetchCatalog(catalogSearch, controller.signal).then(setCatalog).catch(() => setCatalog([]))
return () => controller.abort()
}, [catalogSearch])
const updateOccurrence = (index: number, next: PreparationOccurrence) => {
setInput((current) => ({ ...current,
occurrences: current.occurrences.map((value, position) => position === index ? next : value) }))
}
const add = (choice: CatalogChoice) => setInput((current) => ({ ...current, occurrences: [
...current.occurrences,
{ exercise_id: choice.exercise_id, exercise_name: choice.name, equipment_id: null,
recording_mode: choice.recording_mode, tracking_mode: choice.tracking_mode,
load_mode: 'none', rest_seconds: 0, target_sets: choice.recording_mode === 'sets' ? 1 : null,
target_reps: choice.tracking_mode === 'reps' ? 1 : null,
target_duration_seconds: choice.tracking_mode === 'duration' ? 60 : null,
target_weight_kg: null, notes: null },
] }))
const move = (index: number, delta: number) => setInput((current) => {
const target = index + delta
if (target < 0 || target >= current.occurrences.length) return current
const occurrences = [...current.occurrences]
;[occurrences[index], occurrences[target]] = [occurrences[target], occurrences[index]]
return { ...current, occurrences }
})
const submit = async (ready: boolean) => {
setSaving(true); setError('')
try {
const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft',
occurrences: input.occurrences.map(commandOccurrence) },
initial?.kind === 'preparation' ? initial.identity : undefined,
initial?.kind === 'preparation' ? initial.revision_id : undefined)
if (ready) await prepareForAndroid(result.preparation_id, result.revision_id)
onSaved(result.preparation_id)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Sauvegarde impossible')
} finally { setSaving(false) }
}
return <section className="sessions-editor" aria-labelledby="editor-title">
<div className="sessions-toolbar"><div><p className="eyebrow">PRÉPARATION</p>
<h2 id="editor-title">{initial ? 'Modifier la séance' : 'Nouvelle séance'}</h2></div>
<button type="button" className="quiet-action" onClick={onCancel}>Annuler les modifications</button></div>
{error && <p role="alert" className="form-error">{error}</p>}
<div className="form-grid">
<label>Titre<input value={input.title} maxLength={200}
onChange={(event) => setInput({ ...input, title: event.target.value })} /></label>
<label>Type<select value={input.session_type}
onChange={(event) => setInput({ ...input, session_type: event.target.value as 'training' | 'max_test' })}>
<option value="training">Entraînement</option><option value="max_test">Test MAX</option></select></label>
<label>Date planifiée<input type="date" value={input.planned_for ?? ''}
onChange={(event) => setInput({ ...input, planned_for: event.target.value || null })} /></label>
<label className="wide-field">Note<textarea value={input.notes ?? ''} maxLength={4000}
onChange={(event) => setInput({ ...input, notes: event.target.value || null })} /></label>
</div>
<div className="catalog-picker"><label>Rechercher un exercice<input value={catalogSearch}
onChange={(event) => setCatalogSearch(event.target.value)} /></label>
<div className="catalog-results">{catalog.map((choice) => <button type="button"
key={choice.exercise_id} onClick={() => add(choice)}>+ {choice.name}</button>)}</div></div>
<ol className="occurrence-editor-list">{input.occurrences.map((occurrence, index) =>
<li key={occurrence.entry_id ?? `${occurrence.exercise_id}-${index}`}>
<div className="occurrence-heading"><span className="drag-handle" aria-hidden="true">⋮⋮</span>
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
<span className="occurrence-actions"><button type="button" disabled={index === 0}
aria-label="Monter" onClick={() => move(index, -1)}>↑</button>
<button type="button" disabled={index + 1 === input.occurrences.length}
aria-label="Descendre" onClick={() => move(index, 1)}>↓</button>
<button type="button" onClick={() => setInput({ ...input,
occurrences: input.occurrences.filter((_, position) => position !== index) })}>Retirer</button></span></div>
<div className="occurrence-fields"><label>Charge<select value={occurrence.load_mode}
onChange={(event) => updateOccurrence(index, { ...occurrence,
load_mode: event.target.value as PreparationOccurrence['load_mode'],
target_weight_kg: event.target.value === 'none' ? null : occurrence.target_weight_kg })}>
<option value="none">Aucune</option><option value="external">Externe</option>
<option value="assistance">Assistance</option></select></label>
{occurrence.recording_mode === 'sets' && <label>Séries<input inputMode="numeric"
value={occurrence.target_sets ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_sets: parseDecimal(event.target.value) })} /></label>}
{occurrence.tracking_mode === 'reps' ? <label>Répétitions<input inputMode="numeric"
value={occurrence.target_reps ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_reps: parseDecimal(event.target.value) })} /></label> :
<label>Durée (s)<input inputMode="numeric" value={occurrence.target_duration_seconds ?? ''}
onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_duration_seconds: parseDecimal(event.target.value) })} /></label>}
{occurrence.load_mode !== 'none' && <label>Charge (kg)<input inputMode="decimal"
value={occurrence.target_weight_kg ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_weight_kg: parseDecimal(event.target.value) })} /></label>}
<label>Repos (s)<input inputMode="numeric" value={occurrence.rest_seconds}
onChange={(event) => updateOccurrence(index,
{ ...occurrence, rest_seconds: parseDecimal(event.target.value) ?? 0 })} /></label></div>
</li>)}</ol>
{input.occurrences.length === 0 && <p className="empty-inline">Ajoutez des exercices pour rendre la préparation exécutable.</p>}
<div className="editor-actions"><button type="button" disabled={saving}
onClick={() => void submit(false)}>Enregistrer</button>
<button type="button" className="primary-action" disabled={saving || !input.title || input.occurrences.length === 0}
onClick={() => void submit(true)}>Marquer prête / Préparer pour Android</button></div>
</section>
}
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
const { dateFormat } = useDatePreferences()
const deleteTrigger = useRef<HTMLElement | null>(null)
const [detail, setDetail] = useState<SessionDetail | null>(null)
const [failed, setFailed] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [message, setMessage] = useState('')
const [deleteError, setDeleteError] = useState('')
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
fetchSessionDetail(kind, identity, controller.signal).then((value) => {
if (!controller.signal.aborted) setDetail(value)
}).catch(() => {
if (!controller.signal.aborted) setFailed(true)
})
return () => controller.abort() }, [kind, identity])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
const withdrawn = detail.state === 'withdrawn'
const remove = async () => {
setDeleting(true)
setDeleteError('')
try {
if (kind === 'preparation') {
const result = await withdrawPreparation(detail.identity, detail.revision_id)
setMessage(result.android_cancellation === 'pending'
? 'Supprimée sur le PC — annulation Android à synchroniser.'
: 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.')
setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id })
} else {
await deleteSessionResource('proposal', detail.identity, detail.revision_id)
setMessage('Proposition supprimée. Son retrait sera propagé sans toucher aux préparations dérivées.')
setConfirming(false)
onBack()
return
}
setConfirming(false)
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleting(false)
}
}
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
{!withdrawn && <div className="detail-actions">
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button>
<button
ref={(element) => { deleteTrigger.current = element }}
type="button"
className="danger-action"
onClick={() => setConfirming(true)}
>{deletionLabel(kind)}</button>
</div>}</div>
{message && <p className="success-panel" role="status">{message}</p>}
{deleteError && <p className="form-error" role="alert">{deleteError}</p>}
{confirming && <DeleteConfirmationDialog
title={detail.title || 'Sans titre'}
itemType={deletionType(kind)}
date={detail.planned_for ? formatCivilDate(detail.planned_for, dateFormat) : null}
consequence={deletionConsequence(kind)}
cancelLabel="Conserver"
confirmLabel={deletionLabel(kind)}
busy={deleting}
error={deleteError}
returnFocus={deleteTrigger}
onCancel={() => setConfirming(false)}
onConfirm={() => void remove()}
/>}
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
<h2>{detail.title || 'Sans titre'}</h2><p>
{detail.planned_for === null ? 'Non planifiée' : <time dateTime={detail.planned_for}>
{formatCivilDate(detail.planned_for, dateFormat)}</time>}
{' · '}{sessionStateLabel(kind === 'proposal' ? 'proposals' : 'preparations', detail.state)}
</p>
{detail.withdrawn_at && <p>Retirée le <time dateTime={detail.withdrawn_at}>
{formatDateTime(detail.withdrawn_at, dateFormat)}</time> · {
detail.withdrawal_acknowledged_at
? 'annulation Android consommée'
: 'annulation Android à synchroniser'
}</p>}</header>
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
{detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
<span>Prévu : {occurrence.target_sets ?? '—'} séries · {occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—'}
{occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span>
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
</li>)}</ol></section>
{detail.source_proposal_id && <section className="detail-tile"><h3>Provenance</h3>
<p>Dérivée de la proposition <a href={`/seances/proposal/${encodeURIComponent(detail.source_proposal_id)}`}>
« {detail.source_proposal_title || detail.source_proposal_id} »</a>.</p>
{detail.source_fingerprint && <details><summary>Détail technique</summary>
<code>{detail.source_fingerprint}</code></details>}
</section>}
</article>
}
function ReadonlyDetail({ kind, identity, onBack }: {
kind: 'draft' | 'history'; identity: string; onBack: () => void
}) {
const { dateFormat } = useDatePreferences()
const deleteTrigger = useRef<HTMLElement | null>(null)
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
const [failed, setFailed] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
fetchReadonlySessionDetail(kind, identity, controller.signal).then((value) => {
if (!controller.signal.aborted) setDetail(value)
}).catch(() => {
if (!controller.signal.aborted) setFailed(true)
})
return () => controller.abort() }, [kind, identity])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
const payload = typeof detail.payload === 'object' && detail.payload !== null
? detail.payload as Record<string, unknown> : null
const startedAt = typeof detail.started_at === 'string' ? detail.started_at : null
const endedAt = typeof detail.ended_at === 'string' ? detail.ended_at : null
const revision = typeof detail.revision_id === 'string' ? detail.revision_id : ''
const title = kind === 'draft'
? String(detail.session_type ?? 'Brouillon')
: String(detail.session_type ?? 'Séance')
const remove = async () => {
setDeleting(true)
setDeleteError('')
try {
await deleteSessionResource(kind, identity, revision)
setConfirming(false)
onBack()
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleting(false)
}
}
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
<button
ref={(element) => { deleteTrigger.current = element }}
type="button"
className="danger-action"
disabled={!revision}
onClick={() => setConfirming(true)}
>{deletionLabel(kind)}</button>
</div>
{deleteError && !confirming && <p className="form-error" role="alert">{deleteError}</p>}
{confirming && <DeleteConfirmationDialog
title={title}
itemType={deletionType(kind)}
date={startedAt ? formatDateTime(startedAt, dateFormat) : null}
consequence={deletionConsequence(kind)}
cancelLabel="Conserver"
confirmLabel={deletionLabel(kind)}
busy={deleting}
error={deleteError}
returnFocus={deleteTrigger}
onCancel={() => setConfirming(false)}
onConfirm={() => void remove()}
/>}
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
<h2>{String(detail.session_type ?? 'Séance')}</h2>
<p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
{formatDateTime(startedAt, dateFormat)}</time>} · {kind === 'history'
? endedAt ? <>fin <time dateTime={endedAt}>{formatDateTime(endedAt, dateFormat)}</time></>
: 'heure de fin inconnue'
: `à poursuivre sur Android · ${sessionStateLabel('drafts', String(detail.state ?? ''))}`}</p></header>
{typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>}
{kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences">
{occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}>
<strong>{String(occurrence.exercise_name ?? occurrence.exercise_id)}</strong>
<span>Objectifs : {String(occurrence.target_sets ?? '—')} séries · {String(occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—')}</span>
<span>Réalisé : {Array.isArray(occurrence.performed_sets) && occurrence.performed_sets.length > 0
? occurrence.performed_sets.map((set) => { const values = set as Record<string, unknown>
return `${String(values.reps ?? values.duration_seconds ?? '—')}${values.weight_kg ? ` × ${String(values.weight_kg)} kg` : ''}` }).join(' · ')
: occurrence.max_weight_kg ? `MAX mesuré ${String(occurrence.max_weight_kg)} kg` : 'aucune mesure persistée'}</span>
</li>)}</ol></section>}
{kind === 'draft' && payload && <section className="detail-tile"><h3>État confirmé</h3>
<pre className="draft-payload">{JSON.stringify(payload, null, 2)}</pre></section>}
</article>
}
export function SessionsPage() {
const { dateFormat } = useDatePreferences()
const [view, setView] = useState<View>('preparation')
const [items, setItems] = useState<SessionListEntry[]>([])
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [deleteTarget, setDeleteTarget] = useState<SessionListEntry | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [sendingPreparation, setSendingPreparation] = useState<string | null>(null)
const deleteReturnFocus = useRef<HTMLElement | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const loadRevision = useRef(0)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => {
const controller = new AbortController()
const revision = ++loadRevision.current
setPending(true)
setFailed(false)
if (view === 'programs') {
setItems([])
setPending(false)
return controller
}
const pageRequests = collections[view].map((collection) =>
fetchAllSessionPages(collection, search, controller.signal).then((pageItems) =>
pageItems.map((item) => ({ ...item, collection }))))
Promise.all(pageRequests)
.then((pages) => {
if (revision === loadRevision.current) setItems(sortSessionItems(pages.flat(), view))
}).catch((reason) => {
if (revision === loadRevision.current && !(reason instanceof DOMException && reason.name === 'AbortError')) {
setFailed(true)
}
}).finally(() => {
if (revision === loadRevision.current) setPending(false)
})
return controller }
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
window.addEventListener('popstate', pop); return () => window.removeEventListener('popstate', pop) }, [])
const open = (collection: SessionCollection, identity: string) => {
const kind = collection === 'preparations' ? 'preparation' : collection === 'proposals' ? 'proposal'
: collection === 'drafts' ? 'draft' : 'history'
const path = `/seances/${kind}/${encodeURIComponent(identity)}`
window.history.pushState(null, '', path); setDetailPath(path)
}
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
const programMatch = useMemo(() => detailPath.match(/^\/seances\/program\/([^/]+)$/), [detailPath])
const openProgram = (identity: string) => {
const path = `/seances/program/${encodeURIComponent(identity)}`
window.history.pushState(null, '', path)
setDetailPath(path)
setView('programs')
}
const visibleItems = useMemo(() => sortSessionItems(items.filter((item) => {
if (stateFilter && item.state !== stateFilter) return false
if (dateFrom && (!item.date || item.date < dateFrom)) return false
if (dateTo && (!item.date || item.date > dateTo)) return false
return true
}), view), [items, stateFilter, dateFrom, dateTo, view])
const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items])
const filterStateLabel = (state: string) => {
const owner = items.find((item) => item.state === state)
return owner === undefined ? `État non reconnu (${state})`
: sessionStateLabel(owner.collection, state)
}
const confirmRowDeletion = async () => {
if (!deleteTarget) return
const kind = deletionKind(deleteTarget.collection)
setDeleting(true)
setDeleteError('')
try {
if (kind === 'preparation' || kind === 'proposal') {
const detail = await fetchSessionDetail(kind, deleteTarget.identity)
if (kind === 'preparation') {
await withdrawPreparation(detail.identity, detail.revision_id)
} else {
await deleteSessionResource(kind, detail.identity, detail.revision_id)
}
} else {
const detail = await fetchReadonlySessionDetail(kind, deleteTarget.identity)
const revision = typeof detail.revision_id === 'string' ? detail.revision_id : ''
if (!revision) throw new Error('Cette ressource ne possède pas de révision supprimable.')
await deleteSessionResource(kind, deleteTarget.identity, revision)
}
setDeleteTarget(null)
setStatusMessage(deletionSuccess(kind))
load()
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleting(false)
}
}
const sendPreparation = async (item: SessionListEntry) => {
if (sendingPreparation !== null || item.collection !== 'preparations' ||
item.delivery_state !== 'local') return
setSendingPreparation(item.identity)
setStatusMessage('')
try {
const detail = await fetchSessionDetail('preparation', item.identity)
let revision = detail.revision_id
if (detail.editing_state === 'draft') {
const saved = await savePreparation({
title: detail.title,
session_type: detail.session_type,
planned_for: detail.planned_for,
notes: detail.notes,
editing_state: 'ready',
occurrences: detail.occurrences.map(commandOccurrence),
}, detail.identity, detail.revision_id)
revision = saved.revision_id
}
await prepareForAndroid(detail.identity, revision)
setStatusMessage('Préparation à synchroniser vers Android.')
} catch (reason) {
setStatusMessage(reason instanceof Error
? `Envoi Android impossible : ${reason.message}`
: 'Envoi Android impossible.')
} finally {
setSendingPreparation(null)
load()
}
}
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
return <section className="page"><Detail kind={detailMatch[1]}
identity={decodeURIComponent(detailMatch[2])} onBack={back} onEdit={setEditor} /></section>
}
if (detailMatch) return <section className="page"><ReadonlyDetail
kind={detailMatch[1] as 'draft' | 'history'} identity={decodeURIComponent(detailMatch[2])}
onBack={back} /></section>
if (programMatch) {
return <section className="page">
<ProgramsTab
detailProgramId={decodeURIComponent(programMatch[1])}
onOpen={openProgram}
onBack={back}
/>
</section>
}
return <section className="page sessions-page" aria-labelledby="page-title"><div className="page-heading">
<div><p className="eyebrow">ORGANISER</p><h1 id="page-title">Séances</h1></div>
<p className="page-intro">Préparez sans confondre proposition, brouillon d’exécution et séance réalisée.</p></div>
<div className="sessions-toolbar"><div className="session-tabs" role="tablist">
{(['preparation', 'resume', 'history', 'programs'] as const).map((candidate) =>
<button
type="button"
role="tab"
aria-selected={view === candidate}
key={candidate}
onClick={() => setView(candidate)}
>{viewLabel(candidate)}</button>)}</div>
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
{view === 'programs' && <ProgramsTab
detailProgramId={null}
onOpen={openProgram}
onBack={back}
/>}
{view !== 'programs' && <>
{statusMessage && <p className="success-panel" role="status">{statusMessage}</p>}
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances">
<label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}>
<option value="">Tous</option>{states.map((state) => <option value={state} key={state}>
{filterStateLabel(state)}</option>)}
</select></label>
<label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label>
<label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label>
</div>
<p className="sort-indicator">Date décroissante</p>
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{visibleItems.map((item) => {
const kind = deletionKind(item.collection)
const deliveryLabel = item.collection !== 'preparations' ? null
: item.delivery_state === 'pending' ? 'À synchroniser'
: item.delivery_state === 'acknowledged' ? 'Reçue sur Android'
: item.delivery_state === 'remote_unknown' ? 'État Android inconnu'
: item.delivery_state === 'cancelled' ? 'Livraison annulée'
: 'Locale'
const sendLabel = item.editing_state === 'draft'
? 'Marquer prête et envoyer vers Android'
: 'Envoyer vers Android'
return <div className="session-row" key={`${item.collection}-${item.identity}`}>
<button
type="button"
className="session-row-main"
onClick={() => open(item.collection, item.identity)}
>
<span><small>{deletionType(kind)}</small><strong>{item.title || 'Sans titre'}</strong></span>
<span>{item.date === null
? item.collection === 'preparations' || item.collection === 'proposals'
? 'Non planifiée'
: 'Date inconnue'
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
<span>{item.collection === 'preparations'
? `${sessionStateLabel(item.collection, item.editing_state ?? item.state)} · ${deliveryLabel}`
: sessionStateLabel(item.collection, item.state)}</span>
</button>
{item.collection === 'preparations' && item.delivery_state === 'local' && <button
type="button"
className="android-send-action"
aria-label={sendLabel}
title={sendLabel}
disabled={sendingPreparation !== null}
onClick={() => void sendPreparation(item)}
><span aria-hidden="true">⇥</span></button>}
{item.collection === 'preparations' && item.delivery_state !== 'local' && <span
className={`android-delivery-state state-${item.delivery_state}`}
>{deliveryLabel}</span>}
<button
type="button"
className="trash-action"
aria-label={deletionLabel(kind)}
title={deletionLabel(kind)}
onClick={(event) => {
deleteReturnFocus.current = event.currentTarget
setDeleteError('')
setDeleteTarget(item)
}}
><span aria-hidden="true">🗑</span></button>
</div>
})}</div>
{deleteTarget && <DeleteConfirmationDialog
title={deleteTarget.title || 'Sans titre'}
itemType={deletionType(deletionKind(deleteTarget.collection))}
date={deleteTarget.date ? formatCivilDate(deleteTarget.date, dateFormat) : null}
consequence={deletionConsequence(deletionKind(deleteTarget.collection))}
cancelLabel="Conserver"
confirmLabel={deletionLabel(deletionKind(deleteTarget.collection))}
busy={deleting}
error={deleteError}
returnFocus={deleteReturnFocus}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmRowDeletion()}
/>}</>}
</section>
}