style(web): separate programs interaction flows
This commit is contained in:
parent
ed9395366e
commit
0cff443893
4 changed files with 518 additions and 10 deletions
188
web/src/api/programs.ts
Normal file
188
web/src/api/programs.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { mutationCsrfToken } from './sync'
|
||||
|
||||
export interface ProgramListItem {
|
||||
program_id: string
|
||||
title: string
|
||||
state: 'active' | 'archived'
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
session_count: number
|
||||
provenance: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProgramOccurrence {
|
||||
entry_id: string
|
||||
position: number
|
||||
exercise_id: string
|
||||
equipment_id: string | null
|
||||
recording_mode: 'sets' | 'continuous'
|
||||
tracking_mode: 'reps' | 'duration'
|
||||
data_fields: number
|
||||
load_mode: 'none' | 'external' | 'assistance'
|
||||
rest_seconds: number
|
||||
target_sets: number | null
|
||||
target_reps: number | null
|
||||
target_duration_seconds: number | null
|
||||
target_weight_kg: number | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface ProgramSession {
|
||||
program_session_id: string
|
||||
position: number
|
||||
title: string
|
||||
session_type: 'training' | 'max_test'
|
||||
planned_for: string | null
|
||||
note: string | null
|
||||
occurrences: ProgramOccurrence[]
|
||||
}
|
||||
|
||||
export interface ProgramDetail {
|
||||
api_version: 1
|
||||
program_id: string
|
||||
title: string
|
||||
note: string | null
|
||||
state: 'active' | 'archived'
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
revision_id: string
|
||||
source_format: 'trainlog-program'
|
||||
source_version: 1
|
||||
source_payload_sha256: string
|
||||
sessions: ProgramSession[]
|
||||
}
|
||||
|
||||
export interface ProgramImportPreview {
|
||||
api_version: 1
|
||||
program_id: string
|
||||
payload_sha256: string
|
||||
session_count: number
|
||||
imported: boolean
|
||||
}
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
async function errorReason(response: Response): Promise<string> {
|
||||
const value: unknown = await response.json()
|
||||
return object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
|
||||
}
|
||||
|
||||
function requestId(): string {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export async function fetchAllPrograms(
|
||||
search = '',
|
||||
state = '',
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProgramListItem[]> {
|
||||
const items: ProgramListItem[] = []
|
||||
const identities = new Set<string>()
|
||||
let offset = 0
|
||||
for (;;) {
|
||||
const query = new URLSearchParams({ offset: String(offset), limit: '24' })
|
||||
if (search.trim()) query.set('search', search.trim())
|
||||
if (state) query.set('state', state)
|
||||
const response = await fetch(`/api/v1/sessions/programs?${query}`, {
|
||||
headers: { Accept: 'application/json' }, signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorReason(response))
|
||||
}
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || value.api_version !== 1 || value.offset !== offset ||
|
||||
!Array.isArray(value.items) || typeof value.more !== 'boolean' ||
|
||||
!Number.isSafeInteger(value.next_offset)) {
|
||||
throw new TypeError('liste de programmes invalide')
|
||||
}
|
||||
for (const candidate of value.items) {
|
||||
if (!object(candidate) || typeof candidate.program_id !== 'string' ||
|
||||
typeof candidate.title !== 'string' ||
|
||||
(candidate.state !== 'active' && candidate.state !== 'archived') ||
|
||||
identities.has(candidate.program_id)) {
|
||||
throw new TypeError('pagination de programmes incohérente')
|
||||
}
|
||||
identities.add(candidate.program_id)
|
||||
items.push(candidate as unknown as ProgramListItem)
|
||||
}
|
||||
if (!value.more) return items
|
||||
if (Number(value.next_offset) <= offset || value.items.length === 0) {
|
||||
throw new TypeError('curseur de programmes obsolète')
|
||||
}
|
||||
offset = Number(value.next_offset)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProgram(programId: string, signal?: AbortSignal): Promise<ProgramDetail> {
|
||||
const response = await fetch(`/api/v1/sessions/program/${encodeURIComponent(programId)}`, {
|
||||
headers: { Accept: 'application/json' }, signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorReason(response))
|
||||
}
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || value.api_version !== 1 || value.program_id !== programId ||
|
||||
!Array.isArray(value.sessions) || typeof value.revision_id !== 'string') {
|
||||
throw new TypeError('programme invalide')
|
||||
}
|
||||
return value as unknown as ProgramDetail
|
||||
}
|
||||
|
||||
async function programMutation(path: string, body: string, extraHeaders = {}): Promise<Response> {
|
||||
const csrf = await mutationCsrfToken()
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'X-Trainlog-CSRF-Token': csrf, 'X-Trainlog-Request-ID': requestId(), ...extraHeaders,
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function validateProgramImport(body: string): Promise<ProgramImportPreview> {
|
||||
const response = await programMutation('/api/v1/sessions/programs/validate', body)
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorReason(response))
|
||||
}
|
||||
return response.json() as Promise<ProgramImportPreview>
|
||||
}
|
||||
|
||||
export async function importProgram(body: string): Promise<ProgramImportPreview> {
|
||||
const response = await programMutation('/api/v1/sessions/programs/import', body)
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorReason(response))
|
||||
}
|
||||
return response.json() as Promise<ProgramImportPreview>
|
||||
}
|
||||
|
||||
export async function archiveProgram(programId: string, revision: string): Promise<void> {
|
||||
const response = await programMutation(
|
||||
`/api/v1/sessions/program/${encodeURIComponent(programId)}/archive`, '{}',
|
||||
{ 'If-Match': `"${revision}"` },
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorReason(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPreparationFromProgram(
|
||||
programId: string,
|
||||
programSessionId: string,
|
||||
): Promise<{ preparation_id: string }> {
|
||||
const response = await programMutation(
|
||||
`/api/v1/sessions/program/${encodeURIComponent(programId)}/sessions/${encodeURIComponent(programSessionId)}/prepare`,
|
||||
'{}',
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorReason(response))
|
||||
}
|
||||
return response.json() as Promise<{ preparation_id: string }>
|
||||
}
|
||||
265
web/src/routes/ProgramsTab.tsx
Normal file
265
web/src/routes/ProgramsTab.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
archiveProgram,
|
||||
createPreparationFromProgram,
|
||||
fetchAllPrograms,
|
||||
fetchProgram,
|
||||
importProgram,
|
||||
validateProgramImport,
|
||||
type ProgramDetail,
|
||||
type ProgramImportPreview,
|
||||
type ProgramListItem,
|
||||
} from '../api/programs'
|
||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||
import { formatCivilDate, formatDateTime } from '../presentation/dateFormat'
|
||||
|
||||
function ProgramDetailView({ programId, onBack, onChanged }: {
|
||||
programId: string
|
||||
onBack: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const { dateFormat } = useDatePreferences()
|
||||
const [program, setProgram] = useState<ProgramDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetchProgram(programId, controller.signal).then(setProgram).catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : 'Programme indisponible')
|
||||
}
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [programId])
|
||||
if (error) {
|
||||
return <p role="alert" className="error-panel">
|
||||
{error} <button onClick={onBack}>Retour</button>
|
||||
</p>
|
||||
}
|
||||
if (!program) {
|
||||
return <p aria-live="polite">Chargement du programme…</p>
|
||||
}
|
||||
const archive = async () => {
|
||||
setError('')
|
||||
try {
|
||||
await archiveProgram(program.program_id, program.revision_id)
|
||||
setProgram(await fetchProgram(program.program_id))
|
||||
setMessage('Programme archivé. Ses définitions et préparations existantes sont conservées.')
|
||||
onChanged()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Archivage impossible')
|
||||
}
|
||||
}
|
||||
const prepare = async (sessionId: string) => {
|
||||
setError('')
|
||||
try {
|
||||
const result = await createPreparationFromProgram(program.program_id, sessionId)
|
||||
setMessage('Préparation créée sans livraison Android automatique.')
|
||||
window.history.pushState(null, '', `/seances/preparation/${encodeURIComponent(result.preparation_id)}`)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Création impossible')
|
||||
}
|
||||
}
|
||||
return <article className="session-detail program-detail">
|
||||
<div className="sessions-toolbar">
|
||||
<button type="button" className="quiet-action" onClick={onBack}>
|
||||
← Retour aux programmes
|
||||
</button>
|
||||
{program.state === 'active' && <button
|
||||
type="button"
|
||||
className="quiet-action"
|
||||
onClick={() => void archive()}
|
||||
>Archiver</button>}
|
||||
</div>
|
||||
{message && <p role="status" className="success-panel">{message}</p>}
|
||||
{error && <p role="alert" className="form-error">{error}</p>}
|
||||
<header className="detail-summary">
|
||||
<p className="eyebrow">
|
||||
PROGRAMME · {program.state === 'active' ? 'ACTIF' : 'ARCHIVÉ'}
|
||||
</p>
|
||||
<h2>{program.title}</h2>
|
||||
{program.note && <p>{program.note}</p>}
|
||||
<p>
|
||||
{program.start_date
|
||||
? formatCivilDate(program.start_date, dateFormat) : 'Début non défini'}
|
||||
{' — '}
|
||||
{program.end_date ? formatCivilDate(program.end_date, dateFormat) : 'fin non définie'}
|
||||
</p>
|
||||
<p>
|
||||
Import {program.source_format} v{program.source_version} · modifié le{' '}
|
||||
{formatDateTime(program.updated_at, dateFormat)}
|
||||
</p>
|
||||
</header>
|
||||
<ol className="program-session-list">
|
||||
{program.sessions.map((session) => <li key={session.program_session_id}>
|
||||
<div>
|
||||
<strong>{session.position + 1}. {session.title}</strong>
|
||||
<p>
|
||||
{session.session_type === 'max_test'
|
||||
? 'Test MAX planifié' : 'Entraînement planifié'}
|
||||
{' · '}{session.occurrences.length} exercice
|
||||
{session.occurrences.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
onClick={() => void prepare(session.program_session_id)}
|
||||
>Créer une préparation</button>
|
||||
</li>)}
|
||||
</ol>
|
||||
</article>
|
||||
}
|
||||
|
||||
export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
|
||||
detailProgramId: string | null
|
||||
onOpen: (programId: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { dateFormat } = useDatePreferences()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [items, setItems] = useState<ProgramListItem[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [state, setState] = useState('')
|
||||
const [pending, setPending] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [rawImport, setRawImport] = useState('')
|
||||
const [preview, setPreview] = useState<ProgramImportPreview | null>(null)
|
||||
const [fileName, setFileName] = useState('')
|
||||
const reload = () => {
|
||||
setPending(true)
|
||||
setError('')
|
||||
const controller = new AbortController()
|
||||
fetchAllPrograms(search, state, controller.signal).then(setItems).catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : 'Liste indisponible')
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setPending(false)
|
||||
}
|
||||
})
|
||||
return controller
|
||||
}
|
||||
useEffect(() => {
|
||||
const controller = reload()
|
||||
return () => controller.abort()
|
||||
}, [search, state])
|
||||
if (detailProgramId) {
|
||||
return <ProgramDetailView
|
||||
programId={detailProgramId}
|
||||
onBack={onBack}
|
||||
onChanged={reload}
|
||||
/>
|
||||
}
|
||||
const choose = async (file: File | undefined) => {
|
||||
setPreview(null)
|
||||
setRawImport('')
|
||||
setError('')
|
||||
setFileName(file?.name ?? '')
|
||||
if (!file) return
|
||||
if (file.size > 512 * 1024) {
|
||||
setError('Le fichier dépasse la limite de 512 Kio.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await file.text()
|
||||
const result = await validateProgramImport(body)
|
||||
setRawImport(body)
|
||||
setPreview(result)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Fichier invalide')
|
||||
}
|
||||
}
|
||||
const confirmImport = async () => {
|
||||
try {
|
||||
const result = await importProgram(rawImport)
|
||||
setPreview(null)
|
||||
setRawImport('')
|
||||
setFileName('')
|
||||
onOpen(result.program_id)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Import impossible')
|
||||
}
|
||||
}
|
||||
const cancelImport = () => {
|
||||
setPreview(null)
|
||||
setRawImport('')
|
||||
}
|
||||
return <section className="programs-tab">
|
||||
<div className="sessions-toolbar">
|
||||
<label className="session-search">
|
||||
<span>Rechercher par titre</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>État
|
||||
<select value={state} onChange={(event) => setState(event.target.value)}>
|
||||
<option value="">Tous</option>
|
||||
<option value="active">Actifs</option>
|
||||
<option value="archived">Archivés</option>
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(event) => void choose(event.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>Importer un programme</button>
|
||||
</div>
|
||||
{preview && <section className="import-preview" aria-labelledby="import-preview-title">
|
||||
<h2 id="import-preview-title">Aperçu avant import</h2>
|
||||
<p><strong>{fileName}</strong></p>
|
||||
<p>
|
||||
Identité : {preview.program_id} · {preview.session_count} séance
|
||||
{preview.session_count > 1 ? 's' : ''}
|
||||
</p>
|
||||
<p>Aucune donnée n’a encore été enregistrée.</p>
|
||||
<div className="confirmation-actions">
|
||||
<button type="button" onClick={cancelImport}>Annuler</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
onClick={() => void confirmImport()}
|
||||
>Importer</button>
|
||||
</div>
|
||||
</section>}
|
||||
{error && <p role="alert" className="error-panel">{error}</p>}
|
||||
{pending && <p aria-live="polite">Chargement…</p>}
|
||||
{!pending && !error && items.length === 0 &&
|
||||
<p className="empty-inline">Aucun programme.</p>}
|
||||
<div className="program-list">
|
||||
{items.map((program) => <button
|
||||
type="button"
|
||||
key={program.program_id}
|
||||
className="session-row program-row"
|
||||
onClick={() => onOpen(program.program_id)}
|
||||
>
|
||||
<span>
|
||||
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
|
||||
<strong>{program.title}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{program.start_date
|
||||
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
|
||||
</span>
|
||||
<span>
|
||||
{program.session_count} séance{program.session_count > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>
|
||||
{program.provenance} · {formatDateTime(program.updated_at, dateFormat)}
|
||||
</span>
|
||||
</button>)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
|
@ -21,12 +21,23 @@ import {
|
|||
validDateSortValue,
|
||||
validTimestampValue,
|
||||
} from '../presentation/dateFormat'
|
||||
import { ProgramsTab } from './ProgramsTab'
|
||||
|
||||
type View = 'preparation' | 'resume' | 'history'
|
||||
const collections: Record<View, SessionCollection[]> = {
|
||||
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 compareIdentity(left: SessionListEntry, right: SessionListEntry): number {
|
||||
|
|
@ -394,10 +405,20 @@ export function SessionsPage() {
|
|||
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)
|
||||
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
|
||||
.then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
|
||||
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) => {
|
||||
|
|
@ -418,6 +439,13 @@ export function SessionsPage() {
|
|||
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
|
||||
|
|
@ -439,14 +467,34 @@ export function SessionsPage() {
|
|||
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'] as const).map((candidate) => <button type="button" role="tab"
|
||||
aria-selected={view === candidate} key={candidate} onClick={() => setView(candidate)}>
|
||||
{candidate === 'preparation' ? 'Préparation' : candidate === 'resume' ? 'À reprendre' : 'Historique'}</button>)}</div>
|
||||
{(['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' && <>
|
||||
<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">
|
||||
|
|
@ -467,6 +515,6 @@ export function SessionsPage() {
|
|||
? 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>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div>
|
||||
<span>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div></>}
|
||||
</section>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,13 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
.delete-confirmation { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .75rem; background: var(--base); }
|
||||
.delete-confirmation h3 { margin-top: 0; }
|
||||
.delete-confirmation p { color: var(--subtext-0); line-height: 1.55; }
|
||||
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
||||
.program-session-list { padding: 0; list-style: none; }
|
||||
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
|
||||
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
|
||||
.import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 600px) { .program-session-list li { align-items: stretch; flex-direction: column; } }
|
||||
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
|
||||
.detail-occurrences li { display: grid; gap: .35rem; }
|
||||
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue