meteo-exam/web/src/views/exam-form.ts
Mathis 6d154ed5fb Météo Exam v0.1.0 — calendrier des évaluations en bulletin météo
- API Express + SQLite : classes, évaluations, SSE, auth Bearer
- Front Vite + TS + Tailwind : frise 7 jours, Exam Cards, modales
- Capacitor Android : APK debug, CapacitorHttp natif
- Seed : classe Terminale S2 (code TERMS2)
2026-10-02 19:51:13 +02:00

235 lines
8.3 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 type { Exam, WeatherKind } from '@meteo-exam/shared'
import { WEATHER, WEATHER_KINDS, WEIGHT_PRESETS, kindFromWeight } from '@meteo-exam/shared'
import { h } from '../dom'
import { isoDayInput } from '../dates'
import { saveExam, setError } from '../store'
import { showToast } from '../toast'
interface Draft {
subject: string
teacher: string
room: string
chapter: string
day: string
time: string
durationMin: number
weight: number
kind: WeatherKind
note: string
}
const inputClass =
'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 transition-colors'
const labelClass = 'block text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1.5'
function field(label: string, control: HTMLElement, hint?: string): HTMLElement {
return h(
'label',
{ class: 'block' },
h('span', { class: labelClass }, label),
control,
hint ? h('span', { class: 'block text-[10px] text-slate-600 font-semibold mt-1' }, hint) : null,
)
}
function draftFrom(exam: Exam | undefined): Draft {
if (exam) {
const date = new Date(exam.startsAt)
return {
subject: exam.subject,
teacher: exam.teacher ?? '',
room: exam.room ?? '',
chapter: exam.chapter ?? '',
day: isoDayInput(date),
time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
durationMin: exam.durationMin,
weight: exam.weight,
kind: exam.kind,
note: exam.note ?? '',
}
}
const now = new Date()
now.setMinutes(0, 0, 0)
now.setHours(Math.max(8, now.getHours() + 1))
return {
subject: '',
teacher: '',
room: '',
chapter: '',
day: isoDayInput(now),
time: `${String(now.getHours()).padStart(2, '0')}:00`,
durationMin: 60,
weight: 2,
kind: 'orage',
note: '',
}
}
function closeOverlay(overlay: HTMLElement): void {
overlay.remove()
}
/** Modale d'ajout / modification. `exam` absent = création. */
export function openExamForm(exam: Exam | undefined, onSaved: () => void): void {
const draft = draftFrom(exam)
const editing = exam !== undefined
const overlay = h('div', {
class: 'fixed inset-0 z-[95] bg-slate-950/80 backdrop-blur-sm flex items-end sm:items-center justify-center',
})
const errorBox = h('p', { class: 'hidden text-xs font-bold text-rose-300 bg-rose-500/10 border border-rose-500/25 rounded-xl px-3 py-2' })
const showError = (message: string): void => {
errorBox.textContent = message
errorBox.classList.remove('hidden')
}
// --- Champs ---------------------------------------------------------------
const subject = h('input', { class: inputClass, value: draft.subject, placeholder: 'Mathématiques' })
const teacher = h('input', { class: inputClass, value: draft.teacher, placeholder: 'M. Roussel' })
const room = h('input', { class: inputClass, value: draft.room, placeholder: 'B204' })
const chapter = h('input', { class: inputClass, value: draft.chapter, placeholder: 'Chapitre ou thème' })
const day = h('input', { type: 'date', class: inputClass, value: draft.day })
const time = h('input', { type: 'time', class: inputClass, value: draft.time })
const note = h('textarea', { class: `${inputClass} min-h-[70px] resize-none`, placeholder: 'Circonscription, calculatrice autorisée…' })
note.value = draft.note
const duration = h('input', { type: 'number', class: inputClass, value: String(draft.durationMin), min: '5', max: '480', step: '5' })
const weight = h('input', { type: 'number', class: inputClass, value: String(draft.weight), min: '0', max: '20', step: '0.5' })
// Le statut découle du coefficient tant qu'on ne le surcharge pas à la main.
let kindOverride = false
const kindPreview = h('div', { class: 'flex flex-wrap gap-1.5' })
const renderKinds = (): void => {
kindPreview.replaceChildren(
...WEATHER_KINDS.map((kind) => {
const active = draft.kind === kind
return h(
'button',
{
type: 'button',
class: `px-2.5 py-1.5 rounded-xl text-[11px] font-extrabold border transition-colors ${
active ? WEATHER[kind].badge : 'bg-slate-800/60 border-slate-700 text-slate-400'
}`,
onclick: () => {
draft.kind = kind
kindOverride = true
renderKinds()
},
},
`${WEATHER[kind].icon} ${WEATHER[kind].label}`,
)
}),
)
}
weight.addEventListener('input', () => {
const value = Number(weight.value)
if (!Number.isFinite(value)) return
draft.weight = value
if (!kindOverride) {
draft.kind = kindFromWeight(value)
renderKinds()
}
})
renderKinds()
// --- Actions --------------------------------------------------------------
const submit = async (): Promise<void> => {
if (!subject.value.trim()) {
showError('La matière est obligatoire.')
return
}
const startsAt = new Date(`${day.value}T${time.value || '08:00'}:00`)
if (Number.isNaN(startsAt.getTime())) {
showError('Date ou heure invalide.')
return
}
const durationMin = Number(duration.value)
if (!Number.isFinite(durationMin) || durationMin < 5) {
showError('Durée minimum : 5 minutes.')
return
}
submitBtn.disabled = true
submitBtn.classList.add('opacity-60')
try {
await saveExam(
{
subject: subject.value.trim(),
teacher: teacher.value.trim() || null,
room: room.value.trim() || null,
chapter: chapter.value.trim() || null,
startsAt: startsAt.toISOString(),
durationMin,
weight: Number(weight.value) || 0,
kind: draft.kind,
note: note.value.trim() || null,
},
exam?.id,
)
showToast(editing ? 'Évaluation mise à jour' : 'Évaluation ajoutée', 'success')
closeOverlay(overlay)
onSaved()
} catch (error) {
setError(error instanceof Error ? error.message : 'Enregistrement impossible')
showError(error instanceof Error ? error.message : 'Enregistrement impossible')
submitBtn.disabled = false
submitBtn.classList.remove('opacity-60')
}
}
const submitBtn = h(
'button',
{ class: 'flex-1 bg-sky-500 text-slate-950 text-sm font-extrabold py-3 rounded-xl active:scale-95 transition-transform', onclick: submit },
editing ? 'Enregistrer' : 'Ajouter au bulletin',
)
const panel = h(
'div',
{ class: 'w-full sm:max-w-md bg-slate-900 border border-slate-700 rounded-t-[2rem] sm:rounded-[2rem] p-5 max-h-[90vh] overflow-y-auto no-scrollbar shadow-2xl' },
h(
'div',
{ class: 'flex items-start justify-between gap-3 mb-4' },
h(
'div',
{},
h('h2', { class: 'text-lg font-black text-slate-100' }, editing ? 'Modifier l’évaluation' : 'Nouvelle évaluation'),
h('p', { class: 'text-xs text-slate-400 font-semibold mt-0.5' }, 'Le statut météo découle du coefficient.'),
),
h('button', { class: 'text-slate-400 hover:text-slate-200 text-lg', onclick: () => closeOverlay(overlay) },
h('i', { class: 'fa-solid fa-xmark' })),
),
h(
'div',
{ class: 'space-y-3.5' },
field('Matière', subject),
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Enseignant', teacher), field('Salle', room)),
field('Chapitre', chapter),
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Date', day), field('Heure', time)),
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Durée (min)', duration), field('Coefficient', weight)),
field(
'Statut météo',
kindPreview,
kindOverride ? 'Statut forcé manuellement.' : WEIGHT_PRESETS.find((p) => p.value === draft.weight)?.label,
),
field('Note', note),
errorBox,
h('div', { class: 'flex gap-2 pt-1' },
h('button', { class: 'bg-slate-800 border border-slate-700 text-slate-300 text-sm font-extrabold px-4 py-3 rounded-xl active:scale-95 transition-transform', onclick: () => closeOverlay(overlay) },
'Annuler'),
submitBtn,
),
),
)
overlay.append(panel)
overlay.addEventListener('click', (event) => {
if (event.target === overlay) closeOverlay(overlay)
})
document.body.append(overlay)
subject.focus()
}