(() => { '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 = `
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 = `