(() => { 'use strict'; // ── constants ─────────────────────────────────────────────────────────── const STATUS = { present: { label: 'Présent', code: 'P', cls: 'st-present', key: '1' }, absent: { label: 'Non-présent', code: 'NP', cls: 'st-absent', key: '2' }, conge: { label: 'Congé', code: 'C', cls: 'st-conge', key: '3' }, rtt: { label: 'RTT', code: 'R', cls: 'st-rtt', key: '4' }, }; const ORDER = ['present', 'absent', 'conge', 'rtt']; const MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']; const WD = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim']; const VIEWS = ['dashboard', 'planning', 'plannings', 'validation', 'companies', 'users', 'history']; const TITLES = { dashboard: 'Tableau de bord', planning: 'Mon planning', plannings: 'Plannings', validation: 'Validation', companies: 'Sociétés', users: 'Utilisateurs', history: 'Historique', }; const now = new Date(); const state = { user: null, view: 'dashboard', y: now.getFullYear(), m: now.getMonth() + 1, cal: null, // last /attendance payload viewing: null, // { id, fullName, companyName } when an admin browses a cadre brush: null, pv: 'grid', pop: null, // { date, period, day } companies: [], users: [], filterCompany: 'all', search: '', validationCompany: null, validation: null, }; const $ = (id) => document.getElementById(id); // ── API ───────────────────────────────────────────────────────────────── async function api(path, options = {}) { const res = await fetch(`/api${path}`, { method: options.method || 'GET', headers: options.body ? { 'Content-Type': 'application/json' } : undefined, body: options.body ? JSON.stringify(options.body) : undefined, credentials: 'include', }); let data = null; try { data = await res.json(); } catch (e) { /* empty body */ } if (!res.ok) { const err = new Error((data && data.error) || `Erreur ${res.status}`); err.status = res.status; throw err; } return data; } let toastTimer = null; function toast(msg, kind) { const el = $('toast'); el.textContent = msg; el.classList.toggle('is-error', kind === 'error'); el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 3000); } const esc = (s) => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }; const initials = (n) => String(n || '').trim().split(/\s+/).map((w) => w[0] || '').join('').slice(0, 2).toUpperCase(); const monthLabel = () => `${MONTHS[state.m - 1]} ${state.y}`; const iso = (d) => `${state.y}-${String(state.m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const halfToDays = (n) => (Math.round(n / 2 * 10) / 10); function fmtDateTime(s) { if (!s) return ''; const d = new Date(s); return `${d.toLocaleDateString('fr-FR')} à ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`; } // ── auth ──────────────────────────────────────────────────────────────── async function boot() { try { const me = await api('/auth/me'); await onLogin(me); } catch (e) { $('view-login').hidden = false; $('app').hidden = true; } } async function onLogin(user) { state.user = user; state.viewing = null; $('view-login').hidden = true; $('app').hidden = false; $('user-name').textContent = user.fullName; $('user-initials').textContent = initials(user.fullName); $('user-meta').textContent = user.role === 'admin' ? 'Administrateur' : `Cadre · ${user.companyName || '—'}`; if (user.role === 'admin') await loadCompanies(); renderNav(); await go('dashboard'); } $('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const errBox = $('login-error'); errBox.hidden = true; try { const user = await api('/auth/login', { method: 'POST', body: { email: $('login-email').value.trim(), password: $('login-password').value }, }); await onLogin(user); } catch (err) { errBox.textContent = err.message || 'Connexion impossible'; errBox.hidden = false; } }); async function logout() { await api('/auth/logout', { method: 'POST' }); location.reload(); } $('logout-btn').addEventListener('click', logout); $('logout-btn-mobile').addEventListener('click', logout); // ── navigation ────────────────────────────────────────────────────────── function navGroups() { if (state.user.role === 'admin') { return [ { label: 'Pilotage', items: [ { key: 'dashboard', label: 'Tableau de bord', icon: 'ph-squares-four' }, { key: 'validation', label: 'Validation & export', icon: 'ph-seal-check' }, ] }, { label: 'Équipes', items: [ { key: 'plannings', label: 'Plannings des cadres', icon: 'ph-users-three' }, { key: 'users', label: 'Utilisateurs', icon: 'ph-user-gear' }, ] }, { label: 'Ma saisie', items: [{ key: 'planning', label: 'Mon planning', icon: 'ph-calendar-dots' }] }, { label: 'Configuration', items: [{ key: 'companies', label: 'Sociétés', icon: 'ph-buildings' }] }, ]; } return [ { label: 'Mon mois', items: [ { key: 'dashboard', label: 'Tableau de bord', icon: 'ph-squares-four' }, { key: 'planning', label: 'Mon planning', icon: 'ph-calendar-dots' }, ] }, { label: 'Archives', items: [{ key: 'history', label: 'Historique', icon: 'ph-clock-counter-clockwise' }] }, ]; } // Built once per session. Views must never rebuild it: replacing these nodes // mid-interaction detaches the button the user is clicking. function renderNav() { const groups = navGroups(); $('side-nav').innerHTML = groups.map((g) => `
${esc(g.label)}
`).join(''); const flat = groups.reduce((a, g) => a.concat(g.items), []).slice(0, 4); $('mobile-nav').innerHTML = flat.map((it) => ` `).join(''); } function setNavBadge(key, count) { const el = document.querySelector(`[data-badge="${key}"]`); if (!el) return; el.textContent = count || ''; el.hidden = !count; } document.addEventListener('click', (e) => { const btn = e.target.closest('[data-nav]'); if (!btn) return; go(btn.dataset.nav); }); async function go(view) { state.view = view; state.pop = null; closePop(); if (view !== 'planning') state.viewing = null; VIEWS.forEach((v) => { $(`view-${v}`).hidden = v !== view; }); $('screen-title').textContent = TITLES[view] || 'Presencia'; renderNavCurrent(); try { if (view === 'dashboard') await loadDashboard(); if (view === 'planning') await loadPlanning(); if (view === 'plannings') await loadPlannings(); if (view === 'validation') await loadValidationView(); if (view === 'companies') await loadCompaniesView(); if (view === 'users') await loadUsersView(); if (view === 'history') await loadHistory(); } catch (err) { toast(err.message, 'error'); } } function renderNavCurrent() { document.querySelectorAll('[data-nav]').forEach((b) => { b.setAttribute('aria-current', b.dataset.nav === state.view ? 'page' : 'false'); }); } // ── dashboard ─────────────────────────────────────────────────────────── async function loadDashboard() { const isAdmin = state.user.role === 'admin'; $('dash-greeting').textContent = `Bonjour ${String(state.user.fullName).split(' ')[0]}`; $('dash-company-block').hidden = !isAdmin; const cal = await api(`/attendance?year=${state.y}&month=${state.m}`); const counts = countOf(cal.entries); const workHalves = workdayHalves(); if (isAdmin) { const ov = await api(`/validations/overview?year=${state.y}&month=${state.m}`); setNavBadge('validation', ov.pendingCompanies || 0); $('dash-subtitle').textContent = `Vue d'ensemble de ${monthLabel()} — ${ov.companies.length} société(s), ${ov.totalActiveCadres} cadre(s).`; tiles([ { label: 'Sociétés', value: ov.companies.length, hint: `${ov.totalActiveCadres} cadres actifs` }, { label: 'À valider', value: ov.pendingCompanies, hint: 'sociétés en attente' }, { label: 'Cadres en retard', value: ov.lateCadres, hint: 'mois non validé' }, { label: 'Ma saisie', value: `${counts.filled}/${workHalves}`, hint: 'demi-journées ouvrées' }, ]); const ready = ov.companies.filter((c) => !c.companyValidated && c.activeCadres > 0 && c.validatedCadres === c.activeCadres); const late = ov.companies.filter((c) => !c.companyValidated && c.validatedCadres < c.activeCadres); const actions = []; ready.slice(0, 2).forEach((c) => actions.push({ icon: 'ph-seal-check', title: `${c.name} — prêt à valider`, detail: `Les ${c.activeCadres} cadre(s) actif(s) ont validé leur mois`, tag: 'Validation', onClick: () => { state.validationCompany = String(c.id); go('validation'); }, })); late.slice(0, 2).forEach((c) => actions.push({ icon: 'ph-bell-ringing', title: `${c.name} — ${c.activeCadres - c.validatedCadres} cadre(s) en retard`, detail: `${c.validatedCadres}/${c.activeCadres} ont validé leur mois`, tag: 'Relance', onClick: () => { state.validationCompany = String(c.id); go('validation'); }, })); actions.push({ icon: 'ph-calendar-dots', title: 'Compléter mon propre planning', detail: `${Math.max(0, workHalves - counts.filled)} demi-journées manquantes`, tag: 'Saisie', onClick: () => go('planning'), }); rowActions(actions); renderCompanyMonthRows(ov.companies); } else { const pct = workHalves ? Math.round(counts.filled / workHalves * 100) : 0; $('dash-subtitle').textContent = `Votre mois de ${monthLabel()} est à ${pct} % renseigné.`; tiles([ { label: 'Renseigné', value: `${counts.filled}/${workHalves}`, hint: 'demi-journées ouvrées' }, { label: 'Présent', value: `${halfToDays(counts.present)} j`, hint: 'sur le mois' }, { label: 'Congé', value: `${halfToDays(counts.conge)} j`, hint: 'sur le mois' }, { label: 'RTT', value: `${halfToDays(counts.rtt)} j`, hint: 'sur le mois' }, ]); const validated = cal.cadreValidated || cal.companyValidated; rowActions([ { icon: 'ph-calendar-dots', title: validated ? 'Mois validé — consulter' : 'Continuer ma saisie', detail: validated ? 'Vos saisies sont verrouillées' : `${Math.max(0, workHalves - counts.filled)} demi-journées à renseigner sur ${monthLabel()}`, tag: validated ? 'Validé' : 'À faire', onClick: () => go('planning'), }, { icon: 'ph-seal-check', title: validated ? 'Mois transmis à l’administrateur' : 'Valider mon mois', detail: cal.cadreValidated ? `Validé le ${fmtDateTime(cal.cadreValidatedAt)}` : 'À faire en fin de mois', tag: 'Échéance', onClick: () => go('planning'), }, { icon: 'ph-clock-counter-clockwise', title: 'Consulter l’historique', detail: 'Vos mois précédents et leurs totaux', tag: 'Historique', onClick: () => go('history'), }, ]); } } function tiles(list) { $('dash-tiles').innerHTML = list.map((t) => `
${esc(t.label)} ${esc(t.value)} ${esc(t.hint)}
`).join(''); } function rowActions(list) { const box = $('dash-actions'); box.innerHTML = list.map((a, i) => ` `).join(''); box.querySelectorAll('[data-action-idx]').forEach((b) => { b.addEventListener('click', () => list[+b.dataset.actionIdx].onClick()); }); } function renderCompanyMonthRows(companies) { const tb = $('dash-company-rows'); if (!companies.length) { tb.innerHTML = 'Aucune société. Créez-en une dans « Sociétés ».'; return; } tb.innerHTML = companies.map((c) => { const ready = c.activeCadres > 0 && c.validatedCadres === c.activeCadres; const status = c.companyValidated ? 'Validée' : (ready ? 'Prête à valider' : 'En cours'); const tagCls = c.companyValidated ? 'tag-accent' : (ready ? 'tag-outline' : 'tag-neutral'); return ` ${esc(c.name)} ${c.validatedCadres}/${c.activeCadres} cadres ${status} `; }).join(''); tb.querySelectorAll('[data-open-company]').forEach((b) => { b.addEventListener('click', () => { state.validationCompany = b.dataset.openCompany; go('validation'); }); }); } // ── planning ──────────────────────────────────────────────────────────── function workdayHalves() { const total = new Date(state.y, state.m, 0).getDate(); let n = 0; for (let d = 1; d <= total; d++) { const wd = (new Date(state.y, state.m - 1, d).getDay() + 6) % 7; if (wd < 5) n += 2; } return n; } function countOf(entries) { const c = { present: 0, absent: 0, conge: 0, rtt: 0, filled: 0 }; (entries || []).forEach((e) => { if (c[e.status] !== undefined) { c[e.status]++; c.filled++; } }); return c; } async function loadPlanning() { const qs = new URLSearchParams({ year: state.y, month: state.m }); if (state.viewing) qs.set('user_id', state.viewing.id); state.cal = await api(`/attendance?${qs}`); renderPlanning(); } function renderPlanning() { const data = state.cal; const readOnly = !!state.viewing; const locked = !data.editable; $('month-label').textContent = monthLabel(); $('back-to-plannings').hidden = !readOnly; $('planning-title').textContent = readOnly ? state.viewing.fullName : 'Mon planning'; $('planning-subtitle').textContent = readOnly ? `${state.viewing.companyName || '—'} — consultation et correction par l'administrateur` : 'Cliquez une demi-journée, ou choisissez un statut de saisie rapide puis peignez les cases.'; // lock bar $('lock-icon').className = `ph ${locked ? 'ph-lock-simple' : 'ph-pencil-simple-line'} lockbar-icon`; let title; let detail; if (data.companyValidated) { title = 'Société validée — mois verrouillé'; detail = `Validée le ${fmtDateTime(data.companyValidatedAt)}. Réouvrez la société pour corriger.`; } else if (data.cadreValidated) { title = readOnly ? 'Mois validé par le cadre' : 'Mois validé — saisies verrouillées'; detail = readOnly ? `Validé le ${fmtDateTime(data.cadreValidatedAt)}. Réouvrez le mois pour corriger.` : `Validé le ${fmtDateTime(data.cadreValidatedAt)}. Un administrateur doit réouvrir le mois pour toute correction.`; } else { title = readOnly ? 'Correction autorisée' : 'Saisie ouverte'; detail = readOnly ? 'Vos corrections sont enregistrées immédiatement.' : 'Validez votre mois une fois toutes vos demi-journées renseignées.'; } $('lock-title').textContent = title; $('lock-detail').textContent = detail; $('validate-month-btn').hidden = readOnly || data.cadreValidated || data.companyValidated; $('reopen-month-btn').hidden = !(readOnly && data.cadreValidated && !data.companyValidated); $('fill-workdays-btn').disabled = locked; $('clear-month-btn').disabled = locked; // brushes + view modes $('brush-options').innerHTML = ORDER.map((k) => ` `).join(''); $('brush-hint').textContent = state.brush ? `Saisie rapide active : chaque clic applique « ${STATUS[state.brush].label} »` : 'Astuce : activez un statut ci-dessus pour peindre plusieurs cases d’affilée.'; $('view-options').innerHTML = [ { k: 'grid', label: 'Grille' }, { k: 'dense', label: 'Compact' }, { k: 'list', label: 'Liste' }, ].map((v) => ``).join(''); $('planning-view').className = `pv-${state.pv}`; // grid const entryMap = new Map(); (data.entries || []).forEach((e) => entryMap.set(`${e.date}|${e.period}`, e.status)); const total = new Date(state.y, state.m, 0).getDate(); const first = (new Date(state.y, state.m - 1, 1).getDay() + 6) % 7; const today = new Date(); const todayNum = today.getFullYear() === state.y && today.getMonth() + 1 === state.m ? today.getDate() : null; const cells = []; for (let i = 0; i < first; i++) cells.push('
'); for (let d = 1; d <= total; d++) { const wd = (new Date(state.y, state.m - 1, d).getDay() + 6) % 7; const key = iso(d); const half = (period) => { const s = entryMap.get(`${key}|${period}`); const long = s ? STATUS[s].label : (period === 'AM' ? 'Matin' : 'Après-midi'); const short = s ? STATUS[s].code : period; return ``; }; cells.push(`
${d}${WD[wd]} ${half('AM')}${half('PM')}
`); } while ((cells.length) % 7 !== 0) cells.push('
'); $('cal-grid').innerHTML = cells.join(''); // summary const c = countOf(data.entries); $('planning-summary').innerHTML = [ { label: 'renseigné', value: `${c.filled}/${workdayHalves()}`, dot: 'st-none' }, { label: 'présent', value: `${halfToDays(c.present)} j`, dot: 'st-present' }, { label: 'non-présent', value: `${halfToDays(c.absent)} j`, dot: 'st-absent' }, { label: 'congé', value: `${halfToDays(c.conge)} j`, dot: 'st-conge' }, { label: 'RTT', value: `${halfToDays(c.rtt)} j`, dot: 'st-rtt' }, ].map((s) => ` ${s.value}${s.label}`).join(''); } $('prev-month').addEventListener('click', () => shiftMonth(-1)); $('next-month').addEventListener('click', () => shiftMonth(1)); function shiftMonth(delta) { let { y, m } = state; m += delta; if (m < 1) { m = 12; y -= 1; } if (m > 12) { m = 1; y += 1; } state.y = y; state.m = m; closePop(); loadPlanning().catch((e) => toast(e.message, 'error')); } $('back-to-plannings').addEventListener('click', () => { state.viewing = null; go('plannings'); }); $('brush-options').addEventListener('click', (e) => { const b = e.target.closest('[data-brush]'); if (!b || b.disabled) return; state.brush = state.brush === b.dataset.brush ? null : b.dataset.brush; renderPlanning(); }); $('view-options').addEventListener('click', (e) => { const b = e.target.closest('[data-pv]'); if (!b) return; state.pv = b.dataset.pv; renderPlanning(); }); // half-day click → brush paint or popover $('cal-grid').addEventListener('click', async (e) => { const half = e.target.closest('.half'); if (!half || half.disabled) return; const { date, period } = half.dataset; if (state.brush) return applyHalf(date, period, state.brush); openPop(half, date, period); }); async function applyHalf(date, period, status) { closePop(); try { const body = status ? { date, period, status } : { date, period }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance', { method: status ? 'PUT' : 'DELETE', body }); await loadPlanning(); } catch (err) { toast(err.message, 'error'); } } async function applyWholeDay() { if (!state.pop) return; const { date, period } = state.pop; const current = (state.cal.entries || []).find((e) => e.date === date && e.period === period); const status = (current && current.status) || state.brush || 'present'; closePop(); try { const body = { entries: [{ date, period: 'AM', status }, { date, period: 'PM', status }] }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance/bulk', { method: 'PUT', body }); await loadPlanning(); } catch (err) { toast(err.message, 'error'); } } // ── popover ───────────────────────────────────────────────────────────── function openPop(anchor, date, period) { state.pop = { date, period, day: parseInt(date.slice(8), 10) }; $('pop-title').textContent = `${state.pop.day} ${MONTHS[state.m - 1]} · ${period === 'AM' ? 'matin' : 'après-midi'}`; $('pop-options').innerHTML = ORDER.map((k) => ` `).join(''); const pop = $('pop'); pop.hidden = false; $('pop-backdrop').hidden = false; const r = anchor.getBoundingClientRect(); const pr = pop.getBoundingClientRect(); const left = Math.max(8, Math.min(r.left, window.innerWidth - pr.width - 8)); let top = r.bottom + 6; if (top + pr.height > window.innerHeight - 8) top = Math.max(8, r.top - pr.height - 6); pop.style.left = `${left}px`; pop.style.top = `${top}px`; } function closePop() { state.pop = null; $('pop').hidden = true; $('pop-backdrop').hidden = true; } $('pop-backdrop').addEventListener('click', closePop); $('pop-options').addEventListener('click', (e) => { const b = e.target.closest('[data-status]'); if (!b || !state.pop) return; applyHalf(state.pop.date, state.pop.period, b.dataset.status); }); $('pop-clear').addEventListener('click', () => { if (state.pop) applyHalf(state.pop.date, state.pop.period, null); }); $('pop-whole-day').addEventListener('click', applyWholeDay); window.addEventListener('keydown', (ev) => { if (!state.pop) return; if (ev.key === 'Escape') return closePop(); if (ev.key === 'Backspace') { ev.preventDefault(); return applyHalf(state.pop.date, state.pop.period, null); } if (ev.key === 'Enter') { ev.preventDefault(); return applyWholeDay(); } const i = ['1', '2', '3', '4'].indexOf(ev.key); if (i >= 0) { ev.preventDefault(); applyHalf(state.pop.date, state.pop.period, ORDER[i]); } }); // ── planning actions ──────────────────────────────────────────────────── $('validate-month-btn').addEventListener('click', async () => { if (!confirm('Confirmer la validation de votre mois ? Vous ne pourrez plus le modifier ensuite.')) return; try { await api('/validations/cadre', { method: 'POST', body: { year: state.y, month: state.m } }); toast('Mois validé et transmis à l’administrateur'); await loadPlanning(); } catch (err) { toast(err.message, 'error'); } }); $('reopen-month-btn').addEventListener('click', async () => { if (!state.viewing) return; if (!confirm(`Réouvrir le mois de ${state.viewing.fullName} pour correction ?`)) return; try { await api('/validations/cadre/reopen', { method: 'POST', body: { user_id: state.viewing.id, year: state.y, month: state.m }, }); toast('Mois réouvert pour correction'); await loadPlanning(); } catch (err) { toast(err.message, 'error'); } }); $('fill-workdays-btn').addEventListener('click', async () => { const data = state.cal; if (!data) return; const filled = new Set((data.entries || []).map((e) => `${e.date}|${e.period}`)); const entries = []; const total = new Date(state.y, state.m, 0).getDate(); for (let d = 1; d <= total; d++) { const wd = (new Date(state.y, state.m - 1, d).getDay() + 6) % 7; if (wd > 4) continue; for (const period of ['AM', 'PM']) { if (!filled.has(`${iso(d)}|${period}`)) entries.push({ date: iso(d), period, status: 'present' }); } } if (!entries.length) return toast('Tous les jours ouvrés sont déjà renseignés'); try { const body = { entries }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance/bulk', { method: 'PUT', body }); toast('Jours ouvrés vides remplis en « Présent »'); await loadPlanning(); } catch (err) { toast(err.message, 'error'); } }); $('clear-month-btn').addEventListener('click', async () => { if (!confirm(`Effacer toutes les saisies de ${monthLabel()} ?`)) return; try { const body = { year: state.y, month: state.m }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance/month', { method: 'DELETE', body }); toast('Mois effacé'); await loadPlanning(); } catch (err) { toast(err.message, 'error'); } }); // ── companies ─────────────────────────────────────────────────────────── async function loadCompanies() { state.companies = await api('/companies'); } function companyOptions(sel, { all = false, selected = null } = {}) { sel.innerHTML = (all ? '' : '') + state.companies.map((c) => ``).join(''); } async function loadCompaniesView() { await loadCompanies(); const total = state.companies.reduce((n, c) => n + Number(c.active_cadre_count || 0), 0); $('companies-count').textContent = `${state.companies.length} société(s) · ${total} cadre(s) actif(s)`; const tb = $('companies-rows'); if (!state.companies.length) { tb.innerHTML = 'Aucune société pour l’instant.'; return; } tb.innerHTML = state.companies.map((c) => ` ${esc(c.name)} ${c.active_cadre_count} ${new Date(c.created_at).toLocaleDateString('fr-FR')} `).join(''); } $('companies-rows').addEventListener('click', async (e) => { const ren = e.target.closest('[data-co-rename]'); const del = e.target.closest('[data-co-delete]'); if (ren) return companyDialog(state.companies.find((c) => String(c.id) === ren.dataset.coRename)); if (del) { const c = state.companies.find((x) => String(x.id) === del.dataset.coDelete); if (!confirm(`Supprimer « ${c.name} » ? Les cadres associés perdront leur société.`)) return; try { await api(`/companies/${c.id}`, { method: 'DELETE' }); toast('Société supprimée'); await loadCompaniesView(); } catch (err) { toast(err.message, 'error'); } } }); $('add-company-btn').addEventListener('click', () => companyDialog(null)); function companyDialog(company) { openDialog(`

${company ? 'Renommer la société' : 'Nouvelle société'}

`); $('co-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = $('co-name').value.trim(); try { if (company) await api(`/companies/${company.id}`, { method: 'PUT', body: { name } }); else await api('/companies', { method: 'POST', body: { name } }); closeDialog(); toast('Société enregistrée'); await loadCompaniesView(); } catch (err) { toast(err.message, 'error'); } }); } // ── users ─────────────────────────────────────────────────────────────── async function loadUsersView() { await loadCompanies(); companyOptions($('us-co'), { all: true, selected: state.filterCompany }); $('us-co').value = state.filterCompany; $('us-q').value = state.search; await refreshUsers(); } async function refreshUsers() { const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : ''; state.users = await api(`/users${qs}`); const q = state.search.trim().toLowerCase(); const rows = state.users.filter((u) => !q || `${u.full_name}${u.email}`.toLowerCase().includes(q)); const tb = $('users-rows'); if (!rows.length) { tb.innerHTML = 'Aucun utilisateur ne correspond.'; return; } tb.innerHTML = rows.map((u) => ` ${esc(u.full_name)} ${esc(u.email)} ${u.role === 'admin' ? 'Admin' : 'Cadre'} ${u.company_name ? esc(u.company_name) : '—'} ${u.active ? 'Actif' : 'Désactivé'} `).join(''); } $('us-co').addEventListener('change', (e) => { state.filterCompany = e.target.value; refreshUsers().catch((x) => toast(x.message, 'error')); }); $('us-q').addEventListener('input', (e) => { state.search = e.target.value; refreshUsers().catch((x) => toast(x.message, 'error')); }); $('users-rows').addEventListener('click', async (e) => { const ed = e.target.closest('[data-u-edit]'); const pw = e.target.closest('[data-u-pw]'); const tg = e.target.closest('[data-u-toggle]'); const find = (id) => state.users.find((u) => String(u.id) === id); if (ed) return userDialog(find(ed.dataset.uEdit)); if (pw) return passwordDialog(find(pw.dataset.uPw)); if (tg) { const u = find(tg.dataset.uToggle); try { await api(`/users/${u.id}`, { method: 'PUT', body: { full_name: u.full_name, email: u.email, role: u.role, company_id: u.company_id, active: !u.active }, }); toast(u.active ? `Compte désactivé : ${u.full_name}` : `Compte activé : ${u.full_name}`); await refreshUsers(); } catch (err) { toast(err.message, 'error'); } } }); $('add-user-btn').addEventListener('click', () => userDialog(null)); function userDialog(user) { const edit = !!user; openDialog(`

${edit ? 'Modifier l’utilisateur' : 'Nouvel utilisateur'}

${edit ? '' : `
`}
`); companyOptions($('u-co'), { selected: edit ? user.company_id : null }); const role = $('u-role'); const toggleCo = () => { $('u-co-field').hidden = role.value === 'admin'; }; role.addEventListener('change', toggleCo); toggleCo(); $('u-form').addEventListener('submit', async (e) => { e.preventDefault(); const body = { full_name: $('u-name').value.trim(), email: $('u-email').value.trim(), role: role.value, company_id: role.value === 'admin' ? null : $('u-co').value, }; if (!edit) body.password = $('u-pass').value; else body.active = user.active; try { if (edit) await api(`/users/${user.id}`, { method: 'PUT', body }); else await api('/users', { method: 'POST', body }); closeDialog(); toast('Utilisateur enregistré'); await refreshUsers(); } catch (err) { toast(err.message, 'error'); } }); } function passwordDialog(user) { openDialog(`

Nouveau mot de passe

Pour ${esc(user.full_name)}

`); $('pw-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api(`/users/${user.id}/password`, { method: 'PUT', body: { password: $('pw-new').value } }); closeDialog(); toast('Mot de passe mis à jour'); } catch (err) { toast(err.message, 'error'); } }); } // ── plannings (admin) ─────────────────────────────────────────────────── async function loadPlannings() { await loadCompanies(); companyOptions($('pl-co'), { all: true, selected: state.filterCompany }); $('pl-co').value = state.filterCompany; $('pl-q').value = state.search; $('plannings-subtitle').textContent = `Consultez ou corrigez le planning d'un cadre pour ${monthLabel()}.`; await refreshCadreRows(); } async function refreshCadreRows() { const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : ''; const users = await api(`/users${qs}`); const q = state.search.trim().toLowerCase(); const cadres = users.filter((u) => u.role === 'cadre' && (!q || `${u.full_name}${u.email}`.toLowerCase().includes(q))); // validation state per company, so each row can show Validé / En cours const byCompany = new Map(); for (const cid of new Set(cadres.map((c) => c.company_id).filter(Boolean))) { try { const v = await api(`/validations/company/${cid}?year=${state.y}&month=${state.m}`); v.cadres.forEach((c) => byCompany.set(c.id, c)); } catch (e) { /* company may have been removed */ } } const box = $('cadre-rows'); if (!cadres.length) { box.innerHTML = '

Aucun cadre ne correspond à ce filtre.

'; return; } const work = workdayHalves(); box.innerHTML = cadres.map((u) => { const v = byCompany.get(u.id); const filled = v ? v.filled : 0; const status = !u.active ? 'Inactif' : (v && v.cadreValidated ? 'Validé' : 'En cours'); const tagCls = !u.active ? 'tag-neutral' : (v && v.cadreValidated ? 'tag-accent' : 'tag-outline'); return ``; }).join(''); } $('pl-co').addEventListener('change', (e) => { state.filterCompany = e.target.value; refreshCadreRows().catch((x) => toast(x.message, 'error')); }); $('pl-q').addEventListener('input', (e) => { state.search = e.target.value; refreshCadreRows().catch((x) => toast(x.message, 'error')); }); $('cadre-rows').addEventListener('click', (e) => { const b = e.target.closest('[data-cadre]'); if (!b) return; openCadre({ id: b.dataset.cadre, fullName: b.dataset.name, companyName: b.dataset.co }); }); function openCadre(cadre) { state.viewing = cadre; state.view = 'planning'; VIEWS.forEach((v) => { $(`view-${v}`).hidden = v !== 'planning'; }); $('screen-title').textContent = TITLES.planning; renderNavCurrent(); loadPlanning().catch((err) => toast(err.message, 'error')); } // ── validation & export ───────────────────────────────────────────────── async function loadValidationView() { await loadCompanies(); if (!state.companies.length) { $('validation-company-name').textContent = 'Aucune société'; $('validation-status-text').textContent = 'Créez une société pour commencer.'; $('validation-rows').innerHTML = ''; $('validation-progress').style.width = '0%'; $('validate-company-btn').disabled = true; return; } if (!state.validationCompany || !state.companies.some((c) => String(c.id) === String(state.validationCompany))) { state.validationCompany = String(state.companies[0].id); } companyOptions($('va-co'), { selected: state.validationCompany }); $('va-co').value = state.validationCompany; const opts = []; for (let i = 0; i < 12; i++) { const d = new Date(state.y, state.m - 1 - i, 1); opts.push({ value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, label: `${MONTHS[d.getMonth()]} ${d.getFullYear()}` }); } $('va-mo').innerHTML = opts.map((o) => ``).join(''); $('va-mo').value = `${state.y}-${String(state.m).padStart(2, '0')}`; await refreshValidation(); } async function refreshValidation() { const co = state.companies.find((c) => String(c.id) === String(state.validationCompany)); const data = await api(`/validations/company/${state.validationCompany}?year=${state.y}&month=${state.m}`); state.validation = data; const okCount = data.cadres.filter((c) => c.cadreValidated).length; $('validation-company-name').textContent = `${co ? co.name : ''} — ${monthLabel()}`; $('validation-status-text').textContent = data.companyValidated ? `Société validée le ${fmtDateTime(data.companyValidatedAt)}` : `${okCount} cadre(s) sur ${data.cadres.length} ont validé leur mois`; $('validation-progress').style.width = `${data.cadres.length ? Math.round(okCount / data.cadres.length * 100) : 0}%`; const btn = $('validate-company-btn'); btn.textContent = data.companyValidated ? 'Réouvrir la société' : 'Valider la société'; btn.disabled = !data.companyValidated && !data.allCadresValidated; const work = workdayHalves(); const tb = $('validation-rows'); if (!data.cadres.length) { tb.innerHTML = 'Aucun cadre actif dans cette société.'; return; } tb.innerHTML = data.cadres.map((c) => ` ${esc(c.fullName)} ${c.filled}/${work} ${c.cadreValidated ? 'Validé' : 'En cours'} ${c.cadreValidated && !data.companyValidated ? `` : ''} `).join(''); } $('va-co').addEventListener('change', (e) => { state.validationCompany = e.target.value; refreshValidation().catch((x) => toast(x.message, 'error')); }); $('va-mo').addEventListener('change', (e) => { const [y, m] = e.target.value.split('-').map(Number); state.y = y; state.m = m; refreshValidation().catch((x) => toast(x.message, 'error')); }); $('validation-rows').addEventListener('click', async (e) => { const open = e.target.closest('[data-v-open]'); const reopen = e.target.closest('[data-v-reopen]'); if (open) { const co = state.companies.find((c) => String(c.id) === String(state.validationCompany)); return openCadre({ id: open.dataset.vOpen, fullName: open.dataset.name, companyName: co ? co.name : '' }); } if (reopen) { if (!confirm('Réouvrir le mois de ce cadre pour permettre des corrections ?')) return; try { await api('/validations/cadre/reopen', { method: 'POST', body: { user_id: reopen.dataset.vReopen, year: state.y, month: state.m } }); toast('Mois réouvert pour ce cadre'); await refreshValidation(); } catch (err) { toast(err.message, 'error'); } } }); $('validate-company-btn').addEventListener('click', async () => { const validated = state.validation && state.validation.companyValidated; const msg = validated ? 'Réouvrir la validation de cette société pour ce mois ?' : 'Valider définitivement cette société pour ce mois ? Les plannings seront verrouillés.'; if (!confirm(msg)) return; try { const path = `/validations/company/${state.validationCompany}${validated ? '/reopen' : ''}`; await api(path, { method: 'POST', body: { year: state.y, month: state.m } }); toast(validated ? 'Validation société réouverte' : 'Société validée'); await refreshValidation(); } catch (err) { toast(err.message, 'error'); } }); $('export-pdf-btn').addEventListener('click', () => download('pdf')); $('export-excel-btn').addEventListener('click', () => download('excel')); function download(kind) { if (!state.validationCompany) return; const a = document.createElement('a'); a.href = `/api/export/${kind}/${state.validationCompany}?year=${state.y}&month=${state.m}`; a.download = ''; document.body.appendChild(a); a.click(); a.remove(); } // ── history ───────────────────────────────────────────────────────────── async function loadHistory() { const rows = await api('/attendance/history'); const tb = $('history-rows'); if (!rows.length) { tb.innerHTML = 'Aucune saisie enregistrée pour le moment.'; return; } tb.innerHTML = rows.map((r) => { const status = r.companyValidated ? 'Validé société' : (r.cadreValidated ? 'Validé cadre' : 'En cours'); const tagCls = r.companyValidated ? 'tag-accent' : (r.cadreValidated ? 'tag-outline' : 'tag-neutral'); return ` ${MONTHS[r.month - 1]} ${r.year} ${halfToDays(r.present)} j ${halfToDays(r.absent)} j ${halfToDays(r.conge)} j ${halfToDays(r.rtt)} j ${status} `; }).join(''); } $('history-rows').addEventListener('click', (e) => { const b = e.target.closest('[data-hist]'); if (!b) return; const [y, m] = b.dataset.hist.split('-').map(Number); state.y = y; state.m = m; go('planning'); }); // ── dialog ────────────────────────────────────────────────────────────── function openDialog(html) { $('dialog').innerHTML = html; $('dialog-backdrop').hidden = false; } function closeDialog() { $('dialog-backdrop').hidden = true; $('dialog').innerHTML = ''; } $('dialog-backdrop').addEventListener('click', (e) => { if (e.target === $('dialog-backdrop') || e.target.closest('[data-close]')) closeDialog(); }); boot(); })();