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
|
|
@ -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 type { Member, Role } from '@meteo-exam/shared'
|
||||
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
|
||||
import { api } from '../api'
|
||||
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
|
||||
import { dayKey, formatDuration, formatTime, longDate, shortDate } from '../dates'
|
||||
import {
|
||||
clearDayWeather,
|
||||
currentFlash,
|
||||
currentKlass,
|
||||
dayStatus,
|
||||
examsByDay,
|
||||
getState,
|
||||
isAdmin,
|
||||
isDayOverridden,
|
||||
removeExam,
|
||||
saveExam,
|
||||
setDayWeather,
|
||||
setFlash,
|
||||
signOut,
|
||||
updateClass,
|
||||
|
|
@ -19,6 +28,8 @@ const inputClass =
|
|||
|
||||
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 {
|
||||
const klass = currentKlass()
|
||||
const flash = currentFlash()
|
||||
|
|
@ -50,76 +61,64 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
|
|||
|
||||
const children: (Node | null)[] = []
|
||||
|
||||
// --- Panneau admin ---
|
||||
// === PANNEAU ADMIN COMPLET ================================================
|
||||
if (admin) {
|
||||
const membersSection = h('div', { class: 'pt-3 border-t border-slate-700/60' },
|
||||
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',
|
||||
{ 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,
|
||||
const adminPanel = 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'),
|
||||
),
|
||||
)
|
||||
|
||||
// Charger les membres de manière asynchrone
|
||||
void loadMembers(membersSection.querySelector('#members-list')!, selfMember, rerender)
|
||||
// --- 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: `${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 ---
|
||||
|
|
@ -131,18 +130,14 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
|
|||
serverInput,
|
||||
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.'),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
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: () => {
|
||||
setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
|
||||
showToast('Adresse du serveur enregistrée', 'success')
|
||||
rerender()
|
||||
},
|
||||
h('button', {
|
||||
class: `${btnClass} bg-sky-500 text-slate-950 mt-3`,
|
||||
onclick: () => {
|
||||
setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
|
||||
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',
|
||||
{ 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(
|
||||
'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()
|
||||
},
|
||||
},
|
||||
h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }),
|
||||
'Se déconnecter',
|
||||
),
|
||||
h('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() },
|
||||
}, 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('div', { class: 'grid grid-cols-1 gap-2.5' },
|
||||
...legendItems.map((item) =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' },
|
||||
h('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('div', {},
|
||||
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(
|
||||
h(
|
||||
'p',
|
||||
{ class: 'text-[10px] text-slate-600 font-semibold text-center' },
|
||||
`Météo Exam — v${currentAppVersion()} · Radar de rumeurs en phase 2`,
|
||||
),
|
||||
h('p', { 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))
|
||||
}
|
||||
|
||||
/** 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(
|
||||
container: HTMLElement,
|
||||
selfMember: { id: number; role: Role } | null,
|
||||
|
|
@ -207,19 +383,17 @@ async function loadMembers(
|
|||
try {
|
||||
const members = await api.listMembers()
|
||||
container.replaceChildren()
|
||||
|
||||
if (members.length === 0) {
|
||||
container.append(h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Aucun membre.'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
container.append(memberRow(member, selfMember, container, rerender))
|
||||
container.append(memberRow(member, selfMember, rerender))
|
||||
}
|
||||
} catch (error) {
|
||||
container.replaceChildren(
|
||||
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(
|
||||
member: Member,
|
||||
selfMember: { id: number; role: Role } | null,
|
||||
container: HTMLElement,
|
||||
rerender: () => void,
|
||||
): HTMLElement {
|
||||
const isSelf = selfMember?.id === member.id
|
||||
const isCreator = member.isCreator
|
||||
const isAdmin = member.role === 'admin'
|
||||
const memberIsAdmin = 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')
|
||||
: 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-slate-600/30 text-slate-400 border border-slate-600/50' }, 'Élève')
|
||||
|
||||
const actions = h('div', { class: 'flex gap-1.5 shrink-0' })
|
||||
|
||||
// Promouvoir un élève en admin
|
||||
if (!isAdmin) {
|
||||
actions.append(
|
||||
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 transition-colors',
|
||||
title: 'Promouvoir admin',
|
||||
onclick: () => changeRole(member.id, 'admin', container, rerender),
|
||||
}, '↑ Admin'),
|
||||
)
|
||||
if (!memberIsAdmin) {
|
||||
actions.append(h('button', {
|
||||
class: 'px-2 py-1 rounded-lg bg-sky-500/10 text-sky-400 text-[10px] font-extrabold',
|
||||
onclick: () => changeRole(member.id, 'admin', rerender),
|
||||
}, '↑ Admin'))
|
||||
}
|
||||
|
||||
// Rétrograder un admin (sauf créateur et soi-même)
|
||||
if (isAdmin && !isCreator && !isSelf) {
|
||||
actions.append(
|
||||
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 transition-colors',
|
||||
title: 'Rétrograder élève',
|
||||
onclick: () => changeRole(member.id, 'student', container, rerender),
|
||||
}, '↓ Élève'),
|
||||
)
|
||||
if (memberIsAdmin && !member.isCreator && !isSelf) {
|
||||
actions.append(h('button', {
|
||||
class: 'px-2 py-1 rounded-lg bg-rose-500/10 text-rose-400 text-[10px] font-extrabold',
|
||||
onclick: () => changeRole(member.id, 'student', rerender),
|
||||
}, '↓ Élève'))
|
||||
}
|
||||
|
||||
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('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' },
|
||||
member.nickname.charAt(0).toUpperCase()),
|
||||
h('span', { class: 'text-xs font-bold text-slate-200 truncate' }, member.nickname),
|
||||
|
|
@ -280,21 +442,142 @@ function memberRow(
|
|||
)
|
||||
}
|
||||
|
||||
async function changeRole(
|
||||
memberId: number,
|
||||
role: Role,
|
||||
container: HTMLElement,
|
||||
rerender: () => void,
|
||||
): Promise<void> {
|
||||
async function changeRole(memberId: number, role: Role, rerender: () => void): Promise<void> {
|
||||
try {
|
||||
await api.setMemberRole(memberId, role)
|
||||
showToast(role === 'admin' ? 'Membre promu admin' : 'Membre rétrogradé', 'success')
|
||||
rerender()
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Erreur'
|
||||
showToast(msg, 'reject')
|
||||
// Recharger la liste pour refléter l'état réel
|
||||
const selfMember = getState().session?.member ?? null
|
||||
await loadMembers(container, selfMember, rerender)
|
||||
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
|
||||
}
|
||||
}
|
||||
|
||||
// === 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