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:
parent
24e9b73b40
commit
8c79476d96
3 changed files with 429 additions and 151 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,76 +61,64 @@ 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'),
|
'div',
|
||||||
h('div', { id: 'members-list', class: 'space-y-1.5' },
|
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' },
|
||||||
h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Chargement…')),
|
h('div', { class: 'flex items-center gap-2' },
|
||||||
)
|
h('i', { class: 'fa-solid fa-user-shield text-sky-400' }),
|
||||||
|
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'),
|
||||||
children.push(
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' },
|
|
||||||
h('div', { class: 'flex items-center gap-2' },
|
|
||||||
h('i', { class: 'fa-solid fa-user-shield text-sky-400' }),
|
|
||||||
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Flash info
|
|
||||||
h('div', {},
|
|
||||||
h('span', { class: labelClass }, 'Flash info du jour'),
|
|
||||||
flashInput,
|
|
||||||
h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' },
|
|
||||||
'Message visible par toute la classe sur l’écran Météo.'),
|
|
||||||
h(
|
|
||||||
'button',
|
|
||||||
{
|
|
||||||
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 () => {
|
|
||||||
try {
|
|
||||||
await setFlash(flashInput.value.trim())
|
|
||||||
showToast('Flash info mis à jour', 'success')
|
|
||||||
rerender()
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'Publier le flash info',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Édition classe
|
|
||||||
h('div', { class: 'pt-3 border-t border-slate-700/60' },
|
|
||||||
h('span', { class: labelClass }, 'Nom de la classe'),
|
|
||||||
h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput),
|
|
||||||
h(
|
|
||||||
'button',
|
|
||||||
{
|
|
||||||
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 () => {
|
|
||||||
try {
|
|
||||||
await updateClass(nameInput.value.trim(), levelInput.value.trim() || null)
|
|
||||||
showToast('Classe mise à jour', 'success')
|
|
||||||
rerender()
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'Enregistrer la classe',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Membres
|
|
||||||
membersSection,
|
|
||||||
),
|
),
|
||||||
)
|
|
||||||
|
|
||||||
// Charger les membres de manière asynchrone
|
// --- Flash info ---
|
||||||
void loadMembers(membersSection.querySelector('#members-list')!, selfMember, rerender)
|
h('div', {},
|
||||||
|
h('span', { class: labelClass }, 'Flash info du jour'),
|
||||||
|
flashInput,
|
||||||
|
h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' },
|
||||||
|
'Message visible par toute la classe sur l’écran Météo.'),
|
||||||
|
h('button', {
|
||||||
|
class: `${btnClass} bg-sky-500 text-slate-950 mt-2`,
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await setFlash(flashInput.value.trim())
|
||||||
|
showToast('Flash info mis à jour', 'success')
|
||||||
|
rerender()
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, 'Publier le flash info'),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Édition classe ---
|
||||||
|
h('div', { class: 'pt-3 border-t border-slate-700/60' },
|
||||||
|
h('span', { class: labelClass }, 'Nom de la classe'),
|
||||||
|
h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput),
|
||||||
|
h('button', {
|
||||||
|
class: `${btnClass} bg-indigo-500 text-white mt-2`,
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await updateClass(nameInput.value.trim(), levelInput.value.trim() || null)
|
||||||
|
showToast('Classe mise à jour', 'success')
|
||||||
|
rerender()
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, 'Enregistrer la classe'),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Gestion des évaluations ---
|
||||||
|
examsAdminSection(rerender),
|
||||||
|
|
||||||
|
// --- Météo par jour ---
|
||||||
|
weatherAdminSection(rerender),
|
||||||
|
|
||||||
|
// --- Membres ---
|
||||||
|
membersAdminSection(selfMember, rerender),
|
||||||
|
)
|
||||||
|
children.push(adminPanel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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`,
|
||||||
{
|
onclick: () => {
|
||||||
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',
|
setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
|
||||||
onclick: () => {
|
showToast('Adresse du serveur enregistrée', 'success')
|
||||||
setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
|
rerender()
|
||||||
showToast('Adresse du serveur enregistrée', 'success')
|
|
||||||
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',
|
||||||
{
|
onclick: () => { signOut(); rerender() },
|
||||||
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',
|
}, h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }), 'Se déconnecter'),
|
||||||
onclick: () => {
|
|
||||||
signOut()
|
|
||||||
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue