236 lines
8.3 KiB
TypeScript
236 lines
8.3 KiB
TypeScript
|
|
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()
|
|||
|
|
}
|