v0.5.0 — panel admin complet : évaluations, météo, membres, flash, classe

- Seed : plus de membres fictifs, seuls les vrais apparaissent
- Panel admin centralise TOUT :
  - Flash info du jour
  - Édition de la classe (nom + niveau)
  - Gestion des évaluations (liste, ajout, modification, suppression)
  - Météo par jour (override admin avec bouton forcé/auto)
  - Gestion des membres (promotion/rétrogradation)
- Formulaire d'évaluation complet (matière, prof, salle, chapitre, date, heure, durée, coeff, note)
- Modale météo par jour (soleil, brouillard, vent, orage, tempête)
This commit is contained in:
Mathis 2026-10-04 22:09:07 +02:00
parent 24e9b73b40
commit 8c79476d96
3 changed files with 429 additions and 151 deletions

View file

@ -6,7 +6,6 @@
import { existsSync, rmSync } from 'node:fs' import { existsSync, rmSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { db, nowIso } from './db.js' import { db, nowIso } from './db.js'
import { newToken } from './auth.js'
import type { WeatherKind } from '@meteo-exam/shared' import type { WeatherKind } from '@meteo-exam/shared'
const DB_PATH = process.env['METEO_DB'] ?? resolve(process.cwd(), 'data/meteo-exam.db') const DB_PATH = process.env['METEO_DB'] ?? resolve(process.cwd(), 'data/meteo-exam.db')
@ -88,13 +87,9 @@ const seed = db.transaction(() => {
const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?') const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?')
.get(CLASS_CODE)!.id .get(CLASS_CODE)!.id
// Pas de membre admin créé par le seed : la première personne qui rejoint // Pas de membre fictif : la première personne qui rejoint la classe
// la classe via l'app devient automatiquement admin (cf. joinClass). // via l'app devient automatiquement admin (cf. joinClass).
// Quelques élèves fictifs pour donner du volume aux votes du radar plus tard. // Les vrais membres apparaissent ici quand ils rejoignent.
for (const nick of ['Léa M.', 'Karim B.', 'Chloé D.', 'Inès R.', 'Lucas P.', 'Mehdi A.']) {
db.prepare('INSERT INTO members (class_id, nickname, role, is_creator, token, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(classId, nick, 'student', 0, newToken(), nowIso())
}
const insert = db.prepare( const insert = db.prepare(
`INSERT INTO exams `INSERT INTO exams

View file

@ -199,4 +199,4 @@ export type StreamEvent =
export const CLASS_CODE_LENGTH = 6 export const CLASS_CODE_LENGTH = 6
/** Version de l'application. Incrémentée à chaque release APK. */ /** Version de l'application. Incrémentée à chaque release APK. */
export const APP_VERSION = '0.4.2' export const APP_VERSION = '0.5.0'

View file

@ -1,12 +1,21 @@
import type { Exam, Member, Role, WeatherKind } from '@meteo-exam/shared'
import { WEATHER, WEATHER_KINDS, kindFromWeight } from '@meteo-exam/shared'
import { h, mount } from '../dom' import { h, mount } from '../dom'
import type { Member, Role } from '@meteo-exam/shared'
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
import { api } from '../api' import { api } from '../api'
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
import { dayKey, formatDuration, formatTime, longDate, shortDate } from '../dates'
import { import {
clearDayWeather,
currentFlash, currentFlash,
currentKlass, currentKlass,
dayStatus,
examsByDay,
getState, getState,
isAdmin, isAdmin,
isDayOverridden,
removeExam,
saveExam,
setDayWeather,
setFlash, setFlash,
signOut, signOut,
updateClass, updateClass,
@ -19,6 +28,8 @@ const inputClass =
const labelClass = 'block text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1.5' const labelClass = 'block text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1.5'
const btnClass = 'w-full text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform'
export function renderPlus(root: HTMLElement, rerender: () => void): void { export function renderPlus(root: HTMLElement, rerender: () => void): void {
const klass = currentKlass() const klass = currentKlass()
const flash = currentFlash() const flash = currentFlash()
@ -50,16 +61,9 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
const children: (Node | null)[] = [] const children: (Node | null)[] = []
// --- Panneau admin --- // === PANNEAU ADMIN COMPLET ================================================
if (admin) { if (admin) {
const membersSection = h('div', { class: 'pt-3 border-t border-slate-700/60' }, const adminPanel = h(
h('span', { class: labelClass }, 'Membres de la classe'),
h('div', { id: 'members-list', class: 'space-y-1.5' },
h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Chargement…')),
)
children.push(
h(
'div', 'div',
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' }, { class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' },
h('div', { class: 'flex items-center gap-2' }, h('div', { class: 'flex items-center gap-2' },
@ -67,16 +71,14 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'), h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'),
), ),
// Flash info // --- Flash info ---
h('div', {}, h('div', {},
h('span', { class: labelClass }, 'Flash info du jour'), h('span', { class: labelClass }, 'Flash info du jour'),
flashInput, flashInput,
h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' }, h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' },
'Message visible par toute la classe sur l’écran Météo.'), 'Message visible par toute la classe sur l’écran Météo.'),
h( h('button', {
'button', class: `${btnClass} bg-sky-500 text-slate-950 mt-2`,
{
class: 'mt-2 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: async () => { onclick: async () => {
try { try {
await setFlash(flashInput.value.trim()) await setFlash(flashInput.value.trim())
@ -86,19 +88,15 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
showToast(error instanceof Error ? error.message : 'Erreur', 'reject') showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
} }
}, },
}, }, 'Publier le flash info'),
'Publier le flash info',
),
), ),
// Édition classe // --- Édition classe ---
h('div', { class: 'pt-3 border-t border-slate-700/60' }, h('div', { class: 'pt-3 border-t border-slate-700/60' },
h('span', { class: labelClass }, 'Nom de la classe'), h('span', { class: labelClass }, 'Nom de la classe'),
h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput), h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput),
h( h('button', {
'button', class: `${btnClass} bg-indigo-500 text-white mt-2`,
{
class: 'mt-2 w-full bg-indigo-500 text-white text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: async () => { onclick: async () => {
try { try {
await updateClass(nameInput.value.trim(), levelInput.value.trim() || null) await updateClass(nameInput.value.trim(), levelInput.value.trim() || null)
@ -108,18 +106,19 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
showToast(error instanceof Error ? error.message : 'Erreur', 'reject') showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
} }
}, },
}, }, 'Enregistrer la classe'),
'Enregistrer la classe',
),
), ),
// Membres // --- Gestion des évaluations ---
membersSection, examsAdminSection(rerender),
),
// --- Météo par jour ---
weatherAdminSection(rerender),
// --- Membres ---
membersAdminSection(selfMember, rerender),
) )
children.push(adminPanel)
// Charger les membres de manière asynchrone
void loadMembers(membersSection.querySelector('#members-list')!, selfMember, rerender)
} }
// --- Serveur --- // --- Serveur ---
@ -131,18 +130,14 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
serverInput, serverInput,
h('p', { class: 'text-[10px] text-slate-500 font-semibold mt-2' }, h('p', { class: 'text-[10px] text-slate-500 font-semibold mt-2' },
'Adresse de l’API. Émulateur : 10.0.2.2:8787 · Réseau local : IP du PC · Public : URL Tailscale.'), 'Adresse de l’API. Émulateur : 10.0.2.2:8787 · Réseau local : IP du PC · Public : URL Tailscale.'),
h( h('button', {
'button', class: `${btnClass} bg-sky-500 text-slate-950 mt-3`,
{
class: 'mt-3 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: () => { onclick: () => {
setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL) setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
showToast('Adresse du serveur enregistrée', 'success') showToast('Adresse du serveur enregistrée', 'success')
rerender() rerender()
}, },
}, }, 'Enregistrer l’adresse'),
'Enregistrer l’adresse',
),
), ),
) )
@ -152,18 +147,10 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
'div', 'div',
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' }, { class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' },
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-1' }, 'Session'), h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-1' }, 'Session'),
h( h('button', {
'button',
{
class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: () => { onclick: () => { signOut(); rerender() },
signOut() }, h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }), 'Se déconnecter'),
rerender()
},
},
h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }),
'Se déconnecter',
),
), ),
) )
@ -173,9 +160,7 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-2' }, 'Légende météorologique'), h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-2' }, 'Légende météorologique'),
h('div', { class: 'grid grid-cols-1 gap-2.5' }, h('div', { class: 'grid grid-cols-1 gap-2.5' },
...legendItems.map((item) => ...legendItems.map((item) =>
h( h('div', { class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' },
'div',
{ class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' },
h('span', { class: 'text-3xl' }, item.icon), h('span', { class: 'text-3xl' }, item.icon),
h('div', {}, h('div', {},
h('h4', { class: `text-sm font-black ${item.accent}` }, item.title), h('h4', { class: `text-sm font-black ${item.accent}` }, item.title),
@ -188,17 +173,208 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
) )
children.push( children.push(
h( h('p', { class: 'text-[10px] text-slate-600 font-semibold text-center' },
'p', `Météo Exam — v${currentAppVersion()} · Radar de rumeurs en phase 2`),
{ class: 'text-[10px] text-slate-600 font-semibold text-center' },
`Météo Exam — v${currentAppVersion()} · Radar de rumeurs en phase 2`,
),
) )
mount(root, ...children.filter((c): c is Node => c !== null)) mount(root, ...children.filter((c): c is Node => c !== null))
} }
/** Charge et affiche la liste des membres avec les boutons promote/demote. */ // === SECTION ÉVALUATIONS =====================================================
function examsAdminSection(rerender: () => void): HTMLElement {
const { stripDays } = getState()
const grouped = examsByDay()
const rows = stripDays.flatMap((date) => {
const key = dayKey(date)
const exams = grouped.get(key) ?? []
return exams.map((exam) => examRow(exam, rerender))
})
return h(
'div',
{ class: 'pt-3 border-t border-slate-700/60' },
h('div', { class: 'flex items-center justify-between mb-2' },
h('span', { class: labelClass }, 'Évaluations (7 jours)'),
h('span', { class: 'text-[10px] text-slate-500 font-bold' }, `${rows.length} au total`),
),
h('div', { class: 'space-y-1.5 max-h-[300px] overflow-y-auto no-scrollbar' },
rows.length === 0
? h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Aucune évaluation sur les 7 jours.')
: null,
...rows,
),
h('button', {
class: `${btnClass} bg-sky-500 text-slate-950 mt-2`,
onclick: () => openExamForm(undefined, rerender),
}, h('i', { class: 'fa-solid fa-plus mr-1.5' }), 'Ajouter une évaluation'),
)
}
function examRow(exam: Exam, rerender: () => void): HTMLElement {
const meta = WEATHER[exam.kind]
return h(
'div',
{ class: 'flex items-center justify-between gap-2 bg-slate-900/40 border border-slate-700/40 rounded-xl px-3 py-2' },
h('div', { class: 'flex items-center gap-2 min-w-0' },
h('span', { class: 'text-lg' }, meta.icon),
h('div', { class: 'min-w-0' },
h('p', { class: 'text-xs font-bold text-slate-200 truncate' }, exam.subject),
h('p', { class: 'text-[10px] text-slate-500 font-semibold truncate' },
`${shortDate(new Date(exam.startsAt))} · ${formatTime(exam.startsAt)} · ${formatDuration(exam.durationMin)}`),
),
),
h('div', { class: 'flex gap-1.5 shrink-0' },
h('button', {
class: 'px-2 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-400 text-[10px] font-extrabold',
onclick: () => openExamForm(exam, rerender),
}, 'Modifier'),
h('button', {
class: 'px-2 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-[10px] font-extrabold',
onclick: async () => {
try {
await removeExam(exam.id)
showToast('Évaluation supprimée', 'success')
rerender()
} catch (error) {
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
}
},
}, 'Suppr.'),
),
)
}
// === SECTION MÉTÉO PAR JOUR ===================================================
function weatherAdminSection(rerender: () => void): HTMLElement {
const { stripDays } = getState()
const dayButtons = stripDays.map((date) => {
const key = dayKey(date)
const status = dayStatus(key)
const overridden = isDayOverridden(key)
const meta = WEATHER[status]
return h(
'button',
{
class: `flex items-center gap-2 w-full px-3 py-2 rounded-xl border text-left transition-colors ${
overridden ? 'bg-sky-500/10 border-sky-400/30' : 'bg-slate-900/40 border-slate-700/40'
}`,
onclick: () => openDayWeatherModal(key, rerender),
},
h('span', { class: 'text-lg' }, meta.icon),
h('div', { class: 'flex-1 min-w-0' },
h('p', { class: 'text-xs font-bold text-slate-200' }, shortDate(date)),
h('p', { class: `text-[10px] font-semibold ${meta.accent}` }, meta.title),
),
overridden
? h('span', { class: 'text-[9px] font-black text-sky-400 uppercase' }, 'forcé')
: h('span', { class: 'text-[9px] text-slate-500 font-semibold' }, 'auto'),
)
})
return h(
'div',
{ class: 'pt-3 border-t border-slate-700/60' },
h('span', { class: labelClass }, 'Météo par jour'),
h('p', { class: 'text-[10px] text-slate-600 font-semibold mb-2' },
'Force un statut météo pour un jour. Visible par toute la classe.'),
h('div', { class: 'space-y-1.5' }, ...dayButtons),
)
}
function openDayWeatherModal(dayKeyValue: string, rerender: () => void): void {
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 close = (): void => overlay.remove()
const [y, m, d] = dayKeyValue.split('-').map(Number)
const date = new Date(y!, (m ?? 1) - 1, d ?? 1)
const options = h(
'div',
{ class: 'grid grid-cols-1 gap-2' },
...(['soleil', 'brouillard', 'vent', 'orage', 'tempete'] as const).map((kind) => {
const meta = WEATHER[kind]
return h(
'button',
{
class: `flex items-center gap-3 p-3 rounded-xl border text-left transition-colors bg-slate-800/60 border-slate-700 text-slate-300`,
onclick: async () => {
try {
await setDayWeather(dayKeyValue, kind)
showToast(`Météo forcée : ${meta.label}`, 'success')
close()
rerender()
} catch (error) {
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
}
},
},
h('span', { class: 'text-2xl' }, meta.icon),
h('div', {},
h('p', { class: 'text-sm font-black' }, meta.title),
h('p', { class: 'text-[11px] font-semibold opacity-70' }, meta.hint),
),
)
}),
isDayOverridden(dayKeyValue)
? h('button', {
class: 'w-full mt-2 bg-slate-800 border border-slate-700 text-slate-300 text-xs font-extrabold py-2.5 rounded-xl',
onclick: async () => {
try {
await clearDayWeather(dayKeyValue)
showToast('Override retiré', 'info')
close()
rerender()
} catch (error) {
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
}
},
}, h('i', { class: 'fa-solid fa-rotate-left mr-1.5' }), 'Retirer (météo auto)')
: null,
)
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-[85vh] 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' }, 'Météo du jour'),
h('p', { class: 'text-xs text-slate-400 font-semibold mt-0.5' }, longDate(date)),
),
h('button', { class: 'text-slate-400 text-lg', onclick: close }, h('i', { class: 'fa-solid fa-xmark' })),
),
options,
)
overlay.append(panel)
overlay.addEventListener('click', (event) => { if (event.target === overlay) close() })
document.body.append(overlay)
}
// === SECTION MEMBRES ==========================================================
function membersAdminSection(
selfMember: { id: number; role: Role } | null,
rerender: () => void,
): HTMLElement {
const container = h('div', { id: 'members-list', class: 'space-y-1.5' },
h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Chargement…'))
void loadMembers(container, selfMember, rerender)
return h(
'div',
{ class: 'pt-3 border-t border-slate-700/60' },
h('span', { class: labelClass }, 'Membres de la classe'),
container,
)
}
async function loadMembers( async function loadMembers(
container: HTMLElement, container: HTMLElement,
selfMember: { id: number; role: Role } | null, selfMember: { id: number; role: Role } | null,
@ -207,19 +383,17 @@ async function loadMembers(
try { try {
const members = await api.listMembers() const members = await api.listMembers()
container.replaceChildren() container.replaceChildren()
if (members.length === 0) { if (members.length === 0) {
container.append(h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Aucun membre.')) container.append(h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Aucun membre.'))
return return
} }
for (const member of members) { for (const member of members) {
container.append(memberRow(member, selfMember, container, rerender)) container.append(memberRow(member, selfMember, rerender))
} }
} catch (error) { } catch (error) {
container.replaceChildren( container.replaceChildren(
h('p', { class: 'text-[11px] text-rose-400 font-semibold' }, h('p', { class: 'text-[11px] text-rose-400 font-semibold' },
error instanceof Error ? error.message : 'Erreur de chargement'), error instanceof Error ? error.message : 'Erreur'),
) )
} }
} }
@ -227,49 +401,37 @@ async function loadMembers(
function memberRow( function memberRow(
member: Member, member: Member,
selfMember: { id: number; role: Role } | null, selfMember: { id: number; role: Role } | null,
container: HTMLElement,
rerender: () => void, rerender: () => void,
): HTMLElement { ): HTMLElement {
const isSelf = selfMember?.id === member.id const isSelf = selfMember?.id === member.id
const isCreator = member.isCreator const memberIsAdmin = member.role === 'admin'
const isAdmin = member.role === 'admin'
const badge = isCreator const badge = member.isCreator
? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-amber-500/15 text-amber-400 border border-amber-400/30' }, 'Créateur') ? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-amber-500/15 text-amber-400 border border-amber-400/30' }, 'Créateur')
: isAdmin : memberIsAdmin
? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-sky-500/15 text-sky-400 border border-sky-400/30' }, 'Admin') ? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-sky-500/15 text-sky-400 border border-sky-400/30' }, 'Admin')
: h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-slate-600/30 text-slate-400 border border-slate-600/50' }, 'Élève') : h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-slate-600/30 text-slate-400 border border-slate-600/50' }, 'Élève')
const actions = h('div', { class: 'flex gap-1.5 shrink-0' }) const actions = h('div', { class: 'flex gap-1.5 shrink-0' })
// Promouvoir un élève en admin if (!memberIsAdmin) {
if (!isAdmin) { actions.append(h('button', {
actions.append( class: 'px-2 py-1 rounded-lg bg-sky-500/10 text-sky-400 text-[10px] font-extrabold',
h('button', { onclick: () => changeRole(member.id, 'admin', rerender),
class: 'px-2 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-400 text-[10px] font-extrabold transition-colors', }, '↑ Admin'))
title: 'Promouvoir admin',
onclick: () => changeRole(member.id, 'admin', container, rerender),
}, '↑ Admin'),
)
} }
// Rétrograder un admin (sauf créateur et soi-même) if (memberIsAdmin && !member.isCreator && !isSelf) {
if (isAdmin && !isCreator && !isSelf) { actions.append(h('button', {
actions.append( class: 'px-2 py-1 rounded-lg bg-rose-500/10 text-rose-400 text-[10px] font-extrabold',
h('button', { onclick: () => changeRole(member.id, 'student', rerender),
class: 'px-2 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-[10px] font-extrabold transition-colors', }, '↓ Élève'))
title: 'Rétrograder élève',
onclick: () => changeRole(member.id, 'student', container, rerender),
}, '↓ Élève'),
)
} }
return h( return h(
'div', 'div',
{ class: 'flex items-center justify-between gap-2 bg-slate-900/40 border border-slate-700/40 rounded-xl px-3 py-2' }, { class: 'flex items-center justify-between gap-2 bg-slate-900/40 border border-slate-700/40 rounded-xl px-3 py-2' },
h( h('div', { class: 'flex items-center gap-2 min-w-0' },
'div',
{ class: 'flex items-center gap-2 min-w-0' },
h('span', { class: 'w-7 h-7 rounded-full bg-slate-700/60 flex items-center justify-center text-[10px] font-black text-slate-300 shrink-0' }, h('span', { class: 'w-7 h-7 rounded-full bg-slate-700/60 flex items-center justify-center text-[10px] font-black text-slate-300 shrink-0' },
member.nickname.charAt(0).toUpperCase()), member.nickname.charAt(0).toUpperCase()),
h('span', { class: 'text-xs font-bold text-slate-200 truncate' }, member.nickname), h('span', { class: 'text-xs font-bold text-slate-200 truncate' }, member.nickname),
@ -280,21 +442,142 @@ function memberRow(
) )
} }
async function changeRole( async function changeRole(memberId: number, role: Role, rerender: () => void): Promise<void> {
memberId: number,
role: Role,
container: HTMLElement,
rerender: () => void,
): Promise<void> {
try { try {
await api.setMemberRole(memberId, role) await api.setMemberRole(memberId, role)
showToast(role === 'admin' ? 'Membre promu admin' : 'Membre rétrogradé', 'success') showToast(role === 'admin' ? 'Membre promu admin' : 'Membre rétrogradé', 'success')
rerender() rerender()
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : 'Erreur' showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
showToast(msg, 'reject')
// Recharger la liste pour refléter l'état réel
const selfMember = getState().session?.member ?? null
await loadMembers(container, selfMember, rerender)
} }
} }
// === FORMULAIRE ÉVALUATION ====================================================
function openExamForm(exam: Exam | undefined, rerender: () => void): void {
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 close = (): void => overlay.remove()
const editing = exam !== undefined
const now = new Date()
now.setMinutes(0, 0, 0)
now.setHours(Math.max(8, now.getHours() + 1))
const isoDay = (dt: Date): string =>
`${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
const subject = h('input', { class: inputClass, value: exam?.subject ?? '', placeholder: 'Mathématiques' })
const teacher = h('input', { class: inputClass, value: exam?.teacher ?? '', placeholder: 'M. Roussel' })
const room = h('input', { class: inputClass, value: exam?.room ?? '', placeholder: 'B204' })
const chapter = h('input', { class: inputClass, value: exam?.chapter ?? '', placeholder: 'Chapitre' })
const day = h('input', { type: 'date', class: inputClass, value: exam ? isoDay(new Date(exam.startsAt)) : isoDay(now) })
const time = h('input', {
type: 'time',
class: inputClass,
value: exam
? `${String(new Date(exam.startsAt).getHours()).padStart(2, '0')}:${String(new Date(exam.startsAt).getMinutes()).padStart(2, '0')}`
: `${String(now.getHours()).padStart(2, '0')}:00`,
})
const duration = h('input', { type: 'number', class: inputClass, value: String(exam?.durationMin ?? 60), min: '5', max: '480', step: '5' })
const weight = h('input', { type: 'number', class: inputClass, value: String(exam?.weight ?? 2), min: '0', max: '20', step: '0.5' })
const note = h('textarea', { class: `${inputClass} min-h-[60px] resize-none`, placeholder: 'Note…' })
note.value = exam?.note ?? ''
let kind: WeatherKind = exam?.kind ?? 'orage'
let kindOverride = false
const kindPreview = h('div', { class: 'flex flex-wrap gap-1.5' })
const renderKinds = (): void => {
kindPreview.replaceChildren(
...WEATHER_KINDS.map((k) => {
const meta = WEATHER[k]
const active = kind === k
return h('button', {
type: 'button',
class: `px-2.5 py-1.5 rounded-xl text-[11px] font-extrabold border ${active ? meta.badge : 'bg-slate-800/60 border-slate-700 text-slate-400'}`,
onclick: () => { kind = k; kindOverride = true; renderKinds() },
}, `${meta.icon} ${meta.label}`)
}),
)
}
weight.addEventListener('input', () => {
const v = Number(weight.value)
if (!Number.isFinite(v)) return
if (!kindOverride) { kind = kindFromWeight(v); renderKinds() }
})
renderKinds()
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 = (msg: string): void => { errorBox.textContent = msg; errorBox.classList.remove('hidden') }
const submitBtn = h('button', {
class: 'flex-1 bg-sky-500 text-slate-950 text-sm font-extrabold py-3 rounded-xl',
onclick: async () => {
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 invalide.'); return }
const dur = Number(duration.value)
if (!Number.isFinite(dur) || dur < 5) { showError('Durée minimum : 5 min.'); 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: dur,
weight: Number(weight.value) || 0,
kind,
note: note.value.trim() || null,
}, exam?.id)
showToast(editing ? 'Évaluation mise à jour' : 'Évaluation ajoutée', 'success')
close()
rerender()
} catch (error) {
showError(error instanceof Error ? error.message : 'Erreur')
submitBtn.disabled = false
submitBtn.classList.remove('opacity-60')
}
},
}, editing ? 'Enregistrer' : 'Ajouter')
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 text-lg', onclick: close }, h('i', { class: 'fa-solid fa-xmark' })),
),
h('div', { class: 'space-y-3.5' },
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Matière'), subject),
h('div', { class: 'grid grid-cols-2 gap-3' },
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Enseignant'), teacher),
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Salle'), room)),
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Chapitre'), chapter),
h('div', { class: 'grid grid-cols-2 gap-3' },
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Date'), day),
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Heure'), time)),
h('div', { class: 'grid grid-cols-2 gap-3' },
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Durée (min)'), duration),
h('label', { class: 'block' }, h('span', { class: labelClass }, 'Coefficient'), weight)),
h('div', {}, h('span', { class: labelClass }, 'Statut météo'), kindPreview),
h('label', { class: 'block' }, h('span', { class: labelClass }, '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', onclick: close }, 'Annuler'),
submitBtn),
),
)
overlay.append(panel)
overlay.addEventListener('click', (event) => { if (event.target === overlay) close() })
document.body.append(overlay)
subject.focus()
}