(() => { 'use strict'; const MONTH_NAMES = [ 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre', ]; const STATUS_LABELS = { present: 'Présent', absent: 'Absent', conge: 'Congé', rtt: 'RTT' }; const state = { user: null, companies: [], cal: { year: null, month: null, targetUserId: null, targetUserName: null, data: null }, currentView: 'calendar', }; // ---------- API helper ---------- 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) { /* no body */ } if (!res.ok) { const err = new Error((data && data.error) || `Erreur ${res.status}`); err.status = res.status; err.payload = data; throw err; } return data; } function toast(message, type = 'info') { const container = document.getElementById('toast-container'); const el = document.createElement('div'); el.className = `toast toast-${type}`; el.textContent = message; container.appendChild(el); setTimeout(() => el.remove(), 4000); } function fmtDate(iso) { if (!iso) return ''; const d = new Date(iso); return d.toLocaleDateString('fr-FR') + ' à ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); } // ---------- Auth ---------- async function checkSession() { try { const me = await api('/auth/me'); onLogin(me); } catch (e) { showLogin(); } } function showLogin() { document.getElementById('view-login').hidden = false; document.getElementById('app').hidden = true; } async function onLogin(user) { state.user = user; document.getElementById('view-login').hidden = true; document.getElementById('app').hidden = false; document.getElementById('user-name').textContent = user.fullName; document.getElementById('user-role').textContent = user.role === 'admin' ? 'Administrateur' : (user.companyName || 'Cadre'); const now = new Date(); state.cal.year = now.getFullYear(); state.cal.month = now.getMonth() + 1; state.cal.targetUserId = null; state.cal.targetUserName = null; if (user.role === 'admin') { document.getElementById('admin-nav').hidden = false; await loadCompaniesForSelects(); switchView('companies'); } else { document.getElementById('admin-nav').hidden = true; switchView('calendar'); await loadCalendar(); } } document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const email = document.getElementById('login-email').value.trim(); const password = document.getElementById('login-password').value; const errBox = document.getElementById('login-error'); errBox.hidden = true; try { const user = await api('/auth/login', { method: 'POST', body: { email, password } }); await onLogin(user); } catch (err) { errBox.textContent = err.message || 'Connexion impossible'; errBox.hidden = false; } }); document.getElementById('logout-btn').addEventListener('click', async () => { await api('/auth/logout', { method: 'POST' }); state.user = null; location.reload(); }); // ---------- View switching ---------- const VIEWS = ['calendar', 'companies', 'users', 'plannings', 'validation']; function switchView(name) { state.currentView = name; VIEWS.forEach((v) => { document.getElementById(`view-${v}`).hidden = v !== name; }); document.querySelectorAll('.nav-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.view === name); }); if (name === 'companies') loadCompaniesView(); if (name === 'users') loadUsersView(); if (name === 'plannings') loadPlanningsView(); if (name === 'validation') loadValidationSelectors(); } document.getElementById('admin-nav').addEventListener('click', (e) => { const btn = e.target.closest('.nav-btn'); if (!btn) return; if (btn.dataset.view === 'calendar') { state.cal.targetUserId = null; state.cal.targetUserName = null; document.getElementById('calendar-owner-name').textContent = 'Mon planning'; document.getElementById('calendar-actions').hidden = false; switchView('calendar'); loadCalendar(); return; } switchView(btn.dataset.view); }); // ================= CALENDAR ================= const monthLabel = document.getElementById('month-label'); const calGrid = document.getElementById('calendar-grid'); const validationBanner = document.getElementById('validation-banner'); const validateBtn = document.getElementById('validate-month-btn'); document.getElementById('prev-month').addEventListener('click', () => shiftMonth(-1)); document.getElementById('next-month').addEventListener('click', () => shiftMonth(1)); function shiftMonth(delta) { let { year, month } = state.cal; month += delta; if (month < 1) { month = 12; year -= 1; } if (month > 12) { month = 1; year += 1; } state.cal.year = year; state.cal.month = month; loadCalendar(); } async function loadCalendar() { const { year, month, targetUserId } = state.cal; monthLabel.textContent = `${MONTH_NAMES[month - 1]} ${year}`; const qs = new URLSearchParams({ year, month }); if (targetUserId) qs.set('user_id', targetUserId); try { const data = await api(`/attendance?${qs.toString()}`); state.cal.data = data; renderCalendar(data); } catch (err) { toast(err.message, 'error'); } } function renderCalendar(data) { const { year, month, targetUserId } = state.cal; const entryMap = new Map(); data.entries.forEach((e) => entryMap.set(`${e.date}:${e.period}`, e.status)); // banner const isAdmin = state.user.role === 'admin'; validationBanner.className = 'validation-banner'; if (data.companyValidated) { validationBanner.classList.add('validated'); validationBanner.textContent = `Société validée par l'administrateur le ${fmtDate(data.companyValidatedAt)} — verrouillé`; } else if (data.cadreValidated) { validationBanner.classList.add('locked'); validationBanner.textContent = `Mois validé le ${fmtDate(data.cadreValidatedAt)} — en attente de validation société`; } else { validationBanner.textContent = 'Mois non validé'; } // validate button only shown on own calendar (cadre role, not admin browsing someone else) const actionsBox = document.getElementById('calendar-actions'); if (!isAdmin && !targetUserId) { actionsBox.hidden = false; validateBtn.disabled = data.cadreValidated || data.companyValidated; validateBtn.textContent = data.cadreValidated ? 'Mois déjà validé' : 'Valider mon mois'; } else { actionsBox.hidden = true; } const firstOfMonth = new Date(year, month - 1, 1); const daysInMonth = new Date(year, month, 0).getDate(); let firstWeekday = firstOfMonth.getDay(); // 0=Sun firstWeekday = firstWeekday === 0 ? 7 : firstWeekday; // 1=Mon..7=Sun const leading = firstWeekday - 1; calGrid.innerHTML = ''; for (let i = 0; i < leading; i++) { const empty = document.createElement('div'); empty.className = 'day-cell empty'; calGrid.appendChild(empty); } const editable = data.editable; for (let d = 1; d <= daysInMonth; d++) { const dateObj = new Date(year, month - 1, d); const iso = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6; const cell = document.createElement('div'); cell.className = 'day-cell' + (isWeekend ? ' weekend' : ''); const num = document.createElement('div'); num.className = 'day-number'; num.textContent = d; cell.appendChild(num); const row = document.createElement('div'); row.className = 'half-day-row'; ['AM', 'PM'].forEach((period) => { const status = entryMap.get(`${iso}:${period}`); const half = document.createElement('button'); half.type = 'button'; half.className = 'half-day' + (status ? ` st-${status}` : '') + (editable ? '' : ' readonly'); half.textContent = period; half.dataset.date = iso; half.dataset.period = period; half.dataset.status = status || ''; if (!editable) half.disabled = true; row.appendChild(half); }); cell.appendChild(row); calGrid.appendChild(cell); } } // ---- Half-day popover ---- const popover = document.getElementById('status-popover'); let popoverTarget = null; calGrid.addEventListener('click', (e) => { const half = e.target.closest('.half-day'); if (!half || half.disabled) return; popoverTarget = half; const label = half.dataset.period === 'AM' ? 'Matin' : 'Après-midi'; document.getElementById('popover-title').textContent = `${half.dataset.date} — ${label}`; const rect = half.getBoundingClientRect(); popover.style.top = `${window.scrollY + rect.bottom + 6}px`; popover.style.left = `${window.scrollX + rect.left}px`; popover.hidden = false; }); document.addEventListener('click', (e) => { if (popover.hidden) return; if (e.target.closest('.popover') || e.target.closest('.half-day')) return; popover.hidden = true; }); popover.addEventListener('click', async (e) => { const opt = e.target.closest('.popover-option'); if (!opt || !popoverTarget) return; const status = opt.dataset.status; const { date, period } = popoverTarget.dataset; popover.hidden = true; try { if (status) { const body = { date, period, status }; if (state.cal.targetUserId) body.user_id = state.cal.targetUserId; await api('/attendance', { method: 'PUT', body }); } else { const body = { date, period }; if (state.cal.targetUserId) body.user_id = state.cal.targetUserId; await api('/attendance', { method: 'DELETE', body }); } await loadCalendar(); } catch (err) { toast(err.message, 'error'); } }); validateBtn.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.cal.year, month: state.cal.month } }); toast('Mois validé avec succès', 'success'); await loadCalendar(); } catch (err) { toast(err.message, 'error'); } }); // ================= COMPANIES (admin) ================= async function loadCompaniesForSelects() { try { state.companies = await api('/companies'); } catch (err) { toast(err.message, 'error'); } } async function loadCompaniesView() { await loadCompaniesForSelects(); const tbody = document.getElementById('companies-tbody'); tbody.innerHTML = ''; state.companies.forEach((c) => { const tr = document.createElement('tr'); tr.innerHTML = ` ${escapeHtml(c.name)} ${c.active_cadre_count} ${new Date(c.created_at).toLocaleDateString('fr-FR')} `; tbody.appendChild(tr); }); } document.getElementById('companies-tbody').addEventListener('click', async (e) => { const btn = e.target.closest('button'); if (!btn) return; const id = btn.dataset.id; const company = state.companies.find((c) => String(c.id) === id); if (btn.dataset.action === 'edit') openCompanyModal(company); if (btn.dataset.action === 'delete') { if (!confirm(`Supprimer la société "${company.name}" ? Les cadres associés perdront leur société.`)) return; try { await api(`/companies/${id}`, { method: 'DELETE' }); toast('Société supprimée', 'success'); loadCompaniesView(); } catch (err) { toast(err.message, 'error'); } } }); document.getElementById('add-company-btn').addEventListener('click', () => openCompanyModal(null)); function openCompanyModal(company) { const isEdit = !!company; openModal(`

${isEdit ? 'Modifier la société' : 'Nouvelle société'}

`); document.getElementById('company-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = document.getElementById('company-name').value.trim(); try { if (isEdit) await api(`/companies/${company.id}`, { method: 'PUT', body: { name } }); else await api('/companies', { method: 'POST', body: { name } }); closeModal(); toast('Société enregistrée', 'success'); loadCompaniesView(); } catch (err) { toast(err.message, 'error'); } }); } // ================= USERS (admin) ================= async function loadUsersView() { if (!state.companies.length) await loadCompaniesForSelects(); const companySelect = document.getElementById('users-filter-company'); companySelect.innerHTML = '' + state.companies.map((c) => ``).join(''); await refreshUsersTable(); } document.getElementById('users-filter-company').addEventListener('change', refreshUsersTable); async function refreshUsersTable() { const companyId = document.getElementById('users-filter-company').value; const qs = companyId ? `?company_id=${companyId}` : ''; try { const users = await api(`/users${qs}`); state.users = users; const tbody = document.getElementById('users-tbody'); tbody.innerHTML = ''; users.forEach((u) => { const tr = document.createElement('tr'); tr.innerHTML = ` ${escapeHtml(u.full_name)} ${escapeHtml(u.email)} ${u.role === 'admin' ? 'Admin' : 'Cadre'} ${u.company_name ? escapeHtml(u.company_name) : '—'} ${u.active ? 'Actif' : 'Inactif'} `; tbody.appendChild(tr); }); } catch (err) { toast(err.message, 'error'); } } document.getElementById('users-tbody').addEventListener('click', async (e) => { const btn = e.target.closest('button'); if (!btn) return; const id = btn.dataset.id; const user = state.users.find((u) => String(u.id) === id); if (btn.dataset.action === 'edit') openUserModal(user); if (btn.dataset.action === 'password') openPasswordModal(user); if (btn.dataset.action === 'delete') { if (!confirm(`Supprimer l'utilisateur "${user.full_name}" ?`)) return; try { await api(`/users/${id}`, { method: 'DELETE' }); toast('Utilisateur supprimé', 'success'); refreshUsersTable(); } catch (err) { toast(err.message, 'error'); } } }); document.getElementById('add-user-btn').addEventListener('click', () => openUserModal(null)); function companyOptions(selectedId) { return state.companies.map((c) => `` ).join(''); } function openUserModal(user) { const isEdit = !!user; openModal(`

${isEdit ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur'}

${!isEdit ? ` ` : ''} ${isEdit ? ` ` : ''}
`); const roleSelect = document.getElementById('user-role'); const companyField = document.getElementById('user-company-field'); function toggleCompanyField() { companyField.style.display = roleSelect.value === 'admin' ? 'none' : 'block'; } roleSelect.addEventListener('change', toggleCompanyField); toggleCompanyField(); document.getElementById('user-form').addEventListener('submit', async (e) => { e.preventDefault(); const body = { full_name: document.getElementById('user-fullname').value.trim(), email: document.getElementById('user-email').value.trim(), role: roleSelect.value, company_id: roleSelect.value === 'admin' ? null : document.getElementById('user-company').value, }; if (!isEdit) body.password = document.getElementById('user-password').value; if (isEdit) body.active = document.getElementById('user-active').value === 'true'; try { if (isEdit) await api(`/users/${user.id}`, { method: 'PUT', body }); else await api('/users', { method: 'POST', body }); closeModal(); toast('Utilisateur enregistré', 'success'); refreshUsersTable(); } catch (err) { toast(err.message, 'error'); } }); } function openPasswordModal(user) { openModal(`

Nouveau mot de passe

Pour ${escapeHtml(user.full_name)}

`); document.getElementById('password-form').addEventListener('submit', async (e) => { e.preventDefault(); const password = document.getElementById('new-password').value; try { await api(`/users/${user.id}/password`, { method: 'PUT', body: { password } }); closeModal(); toast('Mot de passe mis à jour', 'success'); } catch (err) { toast(err.message, 'error'); } }); } // ================= PLANNINGS (admin browse) ================= async function loadPlanningsView() { if (!state.companies.length) await loadCompaniesForSelects(); const companySelect = document.getElementById('plannings-company-select'); companySelect.innerHTML = state.companies.map((c) => ``).join(''); await refreshPlanningsCadres(); } document.getElementById('plannings-company-select').addEventListener('change', refreshPlanningsCadres); async function refreshPlanningsCadres() { const companyId = document.getElementById('plannings-company-select').value; const cadreSelect = document.getElementById('plannings-cadre-select'); if (!companyId) { cadreSelect.innerHTML = ''; return; } try { const users = await api(`/users?company_id=${companyId}`); const cadres = users.filter((u) => u.role === 'cadre'); cadreSelect.innerHTML = cadres.map((u) => ``).join('') || ''; } catch (err) { toast(err.message, 'error'); } } document.getElementById('plannings-view-btn').addEventListener('click', async () => { const cadreSelect = document.getElementById('plannings-cadre-select'); const userId = cadreSelect.value; if (!userId) { toast('Sélectionnez un cadre', 'error'); return; } const name = cadreSelect.options[cadreSelect.selectedIndex].textContent; state.cal.targetUserId = userId; state.cal.targetUserName = name; document.getElementById('calendar-owner-name').textContent = `Planning de ${name}`; switchView('calendar'); await loadCalendar(); }); // ================= VALIDATION & EXPORT (admin) ================= function loadValidationSelectors() { if (!state.companies.length) return loadCompaniesForSelects().then(loadValidationSelectors); const companySelect = document.getElementById('validation-company-select'); if (!companySelect.dataset.filled) { companySelect.innerHTML = state.companies.map((c) => ``).join(''); companySelect.dataset.filled = '1'; } const monthSelect = document.getElementById('validation-month-select'); if (!monthSelect.dataset.filled) { monthSelect.innerHTML = MONTH_NAMES.map((m, i) => ``).join(''); monthSelect.value = new Date().getMonth() + 1; monthSelect.dataset.filled = '1'; } const yearSelect = document.getElementById('validation-year-select'); if (!yearSelect.dataset.filled) { const currentYear = new Date().getFullYear(); const years = []; for (let y = currentYear - 2; y <= currentYear + 1; y++) years.push(y); yearSelect.innerHTML = years.map((y) => ``).join(''); yearSelect.value = currentYear; yearSelect.dataset.filled = '1'; } } document.getElementById('validation-load-btn').addEventListener('click', loadValidationSummary); let currentValidationCompanyId = null; let currentValidationYear = null; let currentValidationMonth = null; async function loadValidationSummary() { const companyId = document.getElementById('validation-company-select').value; const year = document.getElementById('validation-year-select').value; const month = document.getElementById('validation-month-select').value; currentValidationCompanyId = companyId; currentValidationYear = year; currentValidationMonth = month; try { const data = await api(`/validations/company/${companyId}?year=${year}&month=${month}`); const company = state.companies.find((c) => String(c.id) === companyId); document.getElementById('validation-summary-card').hidden = false; document.getElementById('validation-company-title').textContent = `${company ? company.name : ''} — ${MONTH_NAMES[month - 1]} ${year}`; const statusText = document.getElementById('validation-status-text'); if (data.companyValidated) { statusText.textContent = `Société validée le ${fmtDate(data.companyValidatedAt)}`; } else if (data.allCadresValidated) { statusText.textContent = 'Tous les cadres ont validé — prêt pour validation société'; } else { const pending = data.cadres.filter((c) => !c.cadreValidated).map((c) => c.fullName); statusText.textContent = pending.length ? `En attente de validation : ${pending.join(', ')}` : 'Aucun cadre actif dans cette société'; } document.getElementById('validate-company-btn').disabled = data.companyValidated || !data.allCadresValidated; document.getElementById('reopen-company-btn').disabled = !data.companyValidated; const tbody = document.getElementById('validation-cadres-tbody'); tbody.innerHTML = ''; data.cadres.forEach((c) => { const tr = document.createElement('tr'); tr.innerHTML = ` ${escapeHtml(c.fullName)} ${c.cadreValidated ? 'Validé' : 'En attente'} ${c.cadreValidatedAt ? fmtDate(c.cadreValidatedAt) : '—'} `; tbody.appendChild(tr); }); } catch (err) { toast(err.message, 'error'); } } document.getElementById('validation-cadres-tbody').addEventListener('click', async (e) => { const btn = e.target.closest('button[data-action="reopen-cadre"]'); if (!btn) return; 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: btn.dataset.id, year: currentValidationYear, month: currentValidationMonth }, }); toast('Mois réouvert pour ce cadre', 'success'); loadValidationSummary(); } catch (err) { toast(err.message, 'error'); } }); document.getElementById('validate-company-btn').addEventListener('click', async () => { if (!confirm('Valider définitivement cette société pour ce mois ? Les plannings seront verrouillés.')) return; try { await api(`/validations/company/${currentValidationCompanyId}`, { method: 'POST', body: { year: currentValidationYear, month: currentValidationMonth }, }); toast('Société validée', 'success'); loadValidationSummary(); } catch (err) { toast(err.message, 'error'); } }); document.getElementById('reopen-company-btn').addEventListener('click', async () => { if (!confirm('Réouvrir la validation de cette société pour ce mois ?')) return; try { await api(`/validations/company/${currentValidationCompanyId}/reopen`, { method: 'POST', body: { year: currentValidationYear, month: currentValidationMonth }, }); toast('Validation société réouverte', 'success'); loadValidationSummary(); } catch (err) { toast(err.message, 'error'); } }); document.getElementById('export-pdf-btn').addEventListener('click', () => downloadExport('pdf')); document.getElementById('export-excel-btn').addEventListener('click', () => downloadExport('excel')); function downloadExport(kind) { if (!currentValidationCompanyId) return; const url = `/api/export/${kind}/${currentValidationCompanyId}?year=${currentValidationYear}&month=${currentValidationMonth}`; const a = document.createElement('a'); a.href = url; a.download = ''; document.body.appendChild(a); a.click(); a.remove(); } // ================= Modal helpers ================= const modalBackdrop = document.getElementById('modal-backdrop'); const modalBox = document.getElementById('modal-box'); function openModal(html) { modalBox.innerHTML = html; modalBackdrop.hidden = false; } function closeModal() { modalBackdrop.hidden = true; modalBox.innerHTML = ''; } modalBackdrop.addEventListener('click', (e) => { if (e.target === modalBackdrop || e.target.closest('[data-close]')) closeModal(); }); function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str == null ? '' : String(str); return div.innerHTML; } // ---------- Boot ---------- checkSession(); })();