'use strict'; (function () { const MONTH_NAMES = { 1: 'Janvier', 2: 'Février', 3: 'Mars', 4: 'Avril', 5: 'Mai', 6: 'Juin', 7: 'Juillet', 8: 'Août', 9: 'Septembre', 10: 'Octobre', 11: 'Novembre', 12: 'Décembre', }; const MONTH_SHORT = { 1: 'Janv.', 2: 'Févr.', 3: 'Mars', 4: 'Avr.', 5: 'Mai', 6: 'Juin', 7: 'Juil.', 8: 'Août', 9: 'Sept.', 10: 'Oct.', 11: 'Nov.', 12: 'Déc.', }; const METHOD_BADGES = { 'Virement': 'V', 'Prélèvement': 'P', 'Chèque': 'C', 'Espèces': 'E', }; const METHOD_COLORS = { 'Virement': '#15803d', 'Prélèvement': '#2563eb', 'Chèque': '#d97706', 'Espèces': '#9333ea', }; const SVG_NS = 'http://www.w3.org/2000/svg'; const state = { year: new Date().getFullYear(), compareYear: new Date().getFullYear() - 1, currentYear: new Date().getFullYear(), currentMonth: new Date().getMonth() + 1, currentDay: new Date().getDate(), canAccess: true, isAdmin: false, logoUrl: '', bannerUrl: '', activeView: 'grid', activeFilter: 'all', searchQuery: '', showArchived: false, members: [], dues: {}, multiYearStats: { availableYears: [], byYear: {}, }, templates: [], activeTemplateSlug: 'tax_receipt', selectedRecipientIds: new Set(), mailingNoEmailOnly: false, previewMemberId: null, lastFocusedTemplateField: null, permissionsLoaded: false, permissions: { accessMode: 'restricted', allowedGroups: new Set(), allowedUsers: new Set(), availableGroups: [], availableUsers: [], groupSearch: '', userSearch: '', }, }; /** * Génère l'URL vers un endpoint du contrôleur Nextcloud. */ function apiUrl(path) { if (window.OC && typeof window.OC.generateUrl === 'function') { return window.OC.generateUrl('/apps/ul_tresorerie' + path); } return '/index.php/apps/ul_tresorerie' + path; } /** * Exécute un appel fetch vers le backend avec le jeton CSRF de Nextcloud. */ async function apiFetch(path, options = {}) { const headers = Object.assign( { 'requesttoken': (window.OC && window.OC.requestToken) ? window.OC.requestToken : '', 'OCS-APIRequest': 'true', }, options.headers || {} ); if (options.body && !(options.body instanceof FormData) && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } const response = await fetch(apiUrl(path), Object.assign({}, options, { headers })); let payload = null; try { payload = await response.json(); } catch (_err) { payload = null; } if (!response.ok) { const msg = (payload && payload.error) ? payload.error : ('Erreur HTTP ' + response.status); throw new Error(msg); } return payload; } /** * Formate un nombre en euros au format français (ex: 1 234,50 €). */ function formatEuros(amount) { const num = Number(amount) || 0; return num.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2, }) + ' €'; } /** * Formate un montant court pour les axes de graphiques (ex: 1 250 €). */ function formatShortEuros(amount) { const num = Math.round(Number(amount) || 0); return num.toLocaleString('fr-FR') + ' €'; } /** * Affiche une notification temporaire en haut de l'interface. */ let toastTimer = null; function showToast(message, type = 'success') { const toast = document.getElementById('ul-toast'); if (!toast) { return; } toast.textContent = message; toast.className = 'ul-toast ul-toast-' + type; if (toastTimer) { clearTimeout(toastTimer); } toastTimer = setTimeout(() => { toast.classList.add('hidden'); }, 4500); } /** * Retourne le dernier mois exigible pour une année donnée : * - Année passée (< currentYear) : 12 * - Année future (> currentYear) : 0 * - Année en cours (=== currentYear) : * - jusqu'au 10 du mois inclus (currentDay <= 10) : mois précédent (currentMonth - 1) * - après le 10 du mois (currentDay > 10) : mois en cours (currentMonth) */ function getMaxRequiredMonth(year = state.year) { if (year < state.currentYear) { return 12; } if (year > state.currentYear) { return 0; } return state.currentDay > 10 ? state.currentMonth : Math.max(0, state.currentMonth - 1); } /** * Calcule les indicateurs annuels et le niveau d'alerte d'un syndiqué pour l'année sélectionnée. */ function computeMemberMetrics(member) { const memberDues = state.dues[member.id] || {}; const maxCheckMonth = getMaxRequiredMonth(state.year); let totalPaid = 0; let paidMonthsCount = 0; let lastPaidAmount = 0; for (let m = 1; m <= 12; m++) { const cell = memberDues[m]; const amt = cell ? Number(cell.amount) || 0 : 0; if (amt > 0) { totalPaid += amt; paidMonthsCount++; lastPaidAmount = amt; } } totalPaid = Math.round(totalPaid * 100) / 100; const missingMonths = []; const missingMonthsNames = []; for (let m = 1; m <= maxCheckMonth; m++) { const cell = memberDues[m]; const amt = cell ? Number(cell.amount) || 0 : 0; if (amt <= 0) { missingMonths.push(m); missingMonthsNames.push(MONTH_NAMES[m]); } } let alertLevel = 'none'; if (Number(member.is_archived) === 0) { if (maxCheckMonth > 0 && totalPaid === 0) { alertLevel = 'red'; } else if (missingMonths.length > 0) { alertLevel = 'orange'; } else { alertLevel = 'uptodate'; } } const usualAmount = (Number(member.default_amount) > 0) ? Number(member.default_amount) : lastPaidAmount; const creditImpot = Math.round(totalPaid * 0.66 * 100) / 100; const estimatedArrears = Math.round(missingMonths.length * usualAmount * 100) / 100; return { totalPaid, paidMonthsCount, missingMonths, missingMonthsNames, alertLevel, usualAmount, creditImpot, estimatedArrears, }; } /** * Charge l'état complet depuis le serveur pour l'année active. */ async function loadState(year) { try { const previousYear = state.year; const data = await apiFetch('/api/state?year=' + encodeURIComponent(year)); state.year = Number(data.year) || year; state.currentYear = Number(data.currentYear) || new Date().getFullYear(); state.currentMonth = Number(data.currentMonth) || (new Date().getMonth() + 1); state.currentDay = Number(data.currentDay) || new Date().getDate(); state.members = Array.isArray(data.members) ? data.members : []; state.dues = data.dues || {}; state.templates = Array.isArray(data.templates) ? data.templates : []; if (data.multiYearStats) { state.multiYearStats = data.multiYearStats; } if (state.compareYear === state.year || previousYear !== state.year) { state.compareYear = state.year - 1; } updateYearLabels(); renderAll(); syncTemplateEditorFromSlug(state.activeTemplateSlug, true); } catch (err) { showToast('Erreur lors du chargement : ' + err.message, 'error'); } } function updateYearLabels() { const ids = [ 'label-current-year', 'nav-year-indicator', 'summary-year', 'mailing-year-label', 'dash-label-year', 'kpi-year-label', 'chart-main-year-label', 'legend-year-curr', ]; ids.forEach((id) => { const el = document.getElementById(id); if (el) { el.textContent = String(state.year); } }); } /** * Met à jour les compteurs de la barre latérale, la grille, le tableau de bord et la vue publipostage. */ function renderAll() { updateSidebarCounters(); renderGrid(); renderDashboard(); if (state.activeView === 'mailing') { renderMailingRecipients(); renderMailingPreview(); } } /** * Calcule et affiche les compteurs d'alertes sur tous les syndiqués actifs. */ function updateSidebarCounters() { let countAllActive = 0; let countUpToDate = 0; let countOrange = 0; let countRed = 0; let grandTotalActive = 0; state.members.forEach((member) => { const metrics = computeMemberMetrics(member); if (Number(member.is_archived) === 0) { countAllActive++; grandTotalActive += metrics.totalPaid; if (metrics.alertLevel === 'uptodate') { countUpToDate++; } else if (metrics.alertLevel === 'orange') { countOrange++; } else if (metrics.alertLevel === 'red') { countRed++; } } }); setText('count-all', String(countAllActive)); setText('count-uptodate', String(countUpToDate)); setText('count-orange', String(countOrange)); setText('count-orange-inline', String(countOrange)); setText('count-red', String(countRed)); setText('count-red-inline', String(countRed)); setText('summary-active-count', String(countAllActive)); setText('summary-grand-total', formatEuros(grandTotalActive)); } function setText(id, text) { const el = document.getElementById(id); if (el) { el.textContent = text; } } /** * Retourne la liste des syndiqués filtrés selon les critères courants de la grille. */ function getFilteredMembersForGrid() { const q = state.searchQuery.trim().toLowerCase(); return state.members.filter((member) => { if (!state.showArchived && Number(member.is_archived) === 1) { return false; } const metrics = computeMemberMetrics(member); if (state.activeFilter === 'uptodate' && metrics.alertLevel !== 'uptodate') { return false; } if (state.activeFilter === 'orange' && metrics.alertLevel !== 'orange') { return false; } if (state.activeFilter === 'red' && metrics.alertLevel !== 'red') { return false; } if (q !== '') { const haystack = [ member.last_name, member.first_name, member.city, member.email, member.cogitiel_id, member.postal_code, ].join(' ').toLowerCase(); if (!haystack.includes(q)) { return false; } } return true; }); } /** * Construit le tableau annuel des cotisations et met à jour les totaux mensuels et annuels. */ function renderGrid() { const tbody = document.getElementById('dues-tbody'); if (!tbody) { return; } tbody.textContent = ''; const filteredMembers = getFilteredMembersForGrid(); const monthlyTotals = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, }; let grandTotal = 0; updateFilterPill(); if (filteredMembers.length === 0) { const emptyRow = document.createElement('tr'); const emptyCell = document.createElement('td'); emptyCell.colSpan = 14; emptyCell.className = 'ul-empty-cell'; emptyCell.textContent = state.members.length === 0 ? 'Aucun syndiqué en base. Cliquez sur « Importer fichier .asp » ou « + Nouveau syndiqué » pour commencer.' : 'Aucun syndiqué ne correspond aux critères de recherche ou de filtre sélectionnés.'; emptyRow.appendChild(emptyCell); tbody.appendChild(emptyRow); } else { filteredMembers.forEach((member) => { const metrics = computeMemberMetrics(member); const memberDues = state.dues[member.id] || {}; const tr = document.createElement('tr'); tr.dataset.memberId = String(member.id); if (Number(member.is_archived) === 1) { tr.classList.add('ul-row-archived'); } else if (metrics.alertLevel === 'red') { tr.classList.add('ul-row-alert-red'); } else if (metrics.alertLevel === 'orange') { tr.classList.add('ul-row-alert-orange'); } // Colonne 1 figée : Nom, Prénom, Badges d'alerte & bouton édition const tdMember = document.createElement('td'); tdMember.className = 'col-sticky-left ul-member-cell'; const memberTopRow = document.createElement('div'); memberTopRow.className = 'ul-member-top-row'; const nameWrap = document.createElement('div'); nameWrap.className = 'ul-member-name-wrap'; const fullName = document.createElement('span'); fullName.className = 'ul-member-fullname'; fullName.textContent = (member.last_name + ' ' + member.first_name).trim(); nameWrap.appendChild(fullName); const subMeta = document.createElement('span'); subMeta.className = 'ul-member-submeta'; const metaParts = ['#' + member.cogitiel_id]; if (member.city) { metaParts.push(member.city); } subMeta.textContent = metaParts.join(' · '); nameWrap.appendChild(subMeta); const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'ul-btn-edit-member'; editBtn.dataset.action = 'edit-member'; editBtn.dataset.memberId = String(member.id); editBtn.title = 'Modifier la fiche du syndiqué'; editBtn.textContent = '✏️'; memberTopRow.appendChild(nameWrap); memberTopRow.appendChild(editBtn); tdMember.appendChild(memberTopRow); // Badges d'alerte et statut const badgesRow = document.createElement('div'); badgesRow.className = 'ul-badges-row'; if (Number(member.is_archived) === 1) { const bArch = document.createElement('span'); bArch.className = 'ul-badge ul-badge-archived'; bArch.textContent = 'Archivé'; badgesRow.appendChild(bArch); } else if (metrics.alertLevel === 'red') { const bRed = document.createElement('span'); bRed.className = 'ul-badge ul-badge-red'; bRed.textContent = '🚨 Aucun versement'; badgesRow.appendChild(bRed); } else if (metrics.alertLevel === 'orange') { const bOrange = document.createElement('span'); bOrange.className = 'ul-badge ul-badge-orange'; bOrange.textContent = '🟠 ' + metrics.missingMonths.length + ' mois manquant' + (metrics.missingMonths.length > 1 ? 's' : ''); bOrange.title = 'Mois manquants : ' + metrics.missingMonthsNames.join(', '); badgesRow.appendChild(bOrange); } else if (metrics.alertLevel === 'uptodate') { const bGreen = document.createElement('span'); bGreen.className = 'ul-badge ul-badge-green'; bGreen.textContent = '✓ À jour'; badgesRow.appendChild(bGreen); } tdMember.appendChild(badgesRow); tr.appendChild(tdMember); // Colonnes 2 à 13 : les 12 mois for (let m = 1; m <= 12; m++) { const tdMonth = document.createElement('td'); tdMonth.dataset.action = 'open-due'; tdMonth.dataset.memberId = String(member.id); tdMonth.dataset.month = String(m); const due = memberDues[m]; const amount = due ? (Number(due.amount) || 0) : 0; if (amount > 0) { monthlyTotals[m] += amount; tdMonth.className = 'ul-due-cell ul-due-paid'; const amountSpan = document.createElement('span'); amountSpan.className = 'ul-due-amount'; amountSpan.textContent = formatEuros(amount); tdMonth.appendChild(amountSpan); const methodBadge = document.createElement('span'); methodBadge.className = 'ul-due-method-badge'; const methodLetter = METHOD_BADGES[due.payment_method] || 'V'; methodBadge.textContent = methodLetter; tdMonth.appendChild(methodBadge); let tooltip = MONTH_NAMES[m] + ' ' + state.year + ' : ' + formatEuros(amount) + ' (' + due.payment_method + ')'; if (due.note) { tooltip += ' — Note : ' + due.note; const noteDot = document.createElement('span'); noteDot.className = 'ul-due-note-dot'; noteDot.textContent = '•'; tdMonth.appendChild(noteDot); } tdMonth.title = tooltip; } else { tdMonth.className = 'ul-due-cell ul-due-empty'; tdMonth.title = 'Cliquer pour saisir la cotisation de ' + MONTH_NAMES[m] + ' ' + state.year; const plusHint = document.createElement('span'); plusHint.className = 'ul-due-empty-plus'; plusHint.textContent = '+'; tdMonth.appendChild(plusHint); } tr.appendChild(tdMonth); } // Colonne 14 figée à droite : Total Annuel du syndiqué grandTotal += metrics.totalPaid; const tdTotal = document.createElement('td'); tdTotal.className = 'col-sticky-right ul-member-total-cell'; if (metrics.totalPaid === 0) { tdTotal.classList.add('ul-total-zero'); } tdTotal.textContent = formatEuros(metrics.totalPaid); tr.appendChild(tdTotal); tbody.appendChild(tr); }); } // Mise à jour du pied de tableau (Total Mensuel & Grand Total Annuel) setText('tfoot-members-count', String(filteredMembers.length)); for (let m = 1; m <= 12; m++) { const roundedMonth = Math.round(monthlyTotals[m] * 100) / 100; setText('foot-month-' + m, formatEuros(roundedMonth)); } const roundedGrandTotal = Math.round(grandTotal * 100) / 100; setText('foot-grand-total', formatEuros(roundedGrandTotal)); } function updateFilterPill() { const pill = document.getElementById('active-filter-indicator'); if (!pill) { return; } const labels = { all: '', uptodate: 'Filtre actif : 🟢 Cotisations à jour', orange: 'Filtre actif : 🟠 Cotisations pas à jour (Mois manquants)', red: 'Filtre actif : 🔴 Aucun versement sur l\'année (0,00 €)', }; const text = labels[state.activeFilter] || ''; if (text === '') { pill.classList.add('hidden'); pill.textContent = ''; } else { pill.classList.remove('hidden'); pill.textContent = text + ' (Cliquer pour afficher tous)'; } } /* ====================================================================== * TABLEAU DE BORD & COMPARAISON PLURIANNUELLE (#view-dashboard) * ====================================================================== */ function getEmptyYearStats(year) { return { year: year, totalAmount: 0, duesCount: 0, payingMembersCount: 0, monthlyTotals: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, }, monthlyCounts: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, }, byMethod: { 'Virement': 0, 'Prélèvement': 0, 'Chèque': 0, 'Espèces': 0, }, }; } function getYearStatsObject(year) { const byYear = (state.multiYearStats && state.multiYearStats.byYear) || {}; return byYear[year] || byYear[String(year)] || getEmptyYearStats(year); } /** * Met à jour l'intégralité du Tableau de bord (Chiffres clés, Graphiques SVG et Tableau comparatif). */ function renderDashboard() { const dashSection = document.getElementById('view-dashboard'); if (!dashSection) { return; } // 1. Synchroniser le sélecteur d'année de comparaison const compareSelect = document.getElementById('dash-select-compare-year'); const availYears = Array.isArray(state.multiYearStats.availableYears) && state.multiYearStats.availableYears.length > 0 ? state.multiYearStats.availableYears : [state.year - 2, state.year - 1, state.year, state.year + 1]; const compareChoices = availYears.filter((y) => Number(y) !== Number(state.year)); if (!compareChoices.includes(state.compareYear)) { state.compareYear = state.year - 1; if (!compareChoices.includes(state.compareYear)) { compareChoices.unshift(state.compareYear); } } if (compareSelect) { compareSelect.textContent = ''; compareChoices.sort((a, b) => Number(b) - Number(a)).forEach((y) => { const opt = document.createElement('option'); opt.value = String(y); const statsY = getYearStatsObject(y); opt.textContent = y + ' (' + formatEuros(statsY.totalAmount) + ')'; compareSelect.appendChild(opt); }); compareSelect.value = String(state.compareYear); } setText('chart-main-year-label', String(state.year)); setText('chart-main-comp-label', String(state.compareYear)); setText('legend-year-curr', String(state.year)); setText('legend-year-comp', String(state.compareYear)); const currStats = getYearStatsObject(state.year); const compStats = getYearStatsObject(state.compareYear); // 2. Calculer les indicateurs syndiqués sur l'année active let activeCount = 0; let upToDateCount = 0; let orangeCount = 0; let redCount = 0; let totalMissingMonths = 0; let totalEstimatedArrears = 0; state.members.forEach((member) => { if (Number(member.is_archived) === 0) { activeCount++; const m = computeMemberMetrics(member); if (m.alertLevel === 'uptodate') { upToDateCount++; } else if (m.alertLevel === 'orange') { orangeCount++; } else if (m.alertLevel === 'red') { redCount++; } totalMissingMonths += m.missingMonths.length; totalEstimatedArrears += m.estimatedArrears; } }); // 3. Remplir les 5 cartes KPI const currTotal = Number(currStats.totalAmount) || 0; const compTotal = Number(compStats.totalAmount) || 0; const diffEuro = currTotal - compTotal; const diffEl = document.getElementById('kpi-total-diff'); if (diffEl) { if (compTotal > 0) { const pct = Math.round(((currTotal - compTotal) / compTotal) * 1000) / 10; const sign = pct >= 0 ? '+' : ''; diffEl.textContent = sign + pct.toLocaleString('fr-FR') + ' % vs ' + state.compareYear; diffEl.className = 'ul-kpi-trend ' + (pct >= 0 ? 'ul-trend-up' : 'ul-trend-down'); } else if (currTotal > 0) { diffEl.textContent = 'Nouveau vs ' + state.compareYear; diffEl.className = 'ul-kpi-trend ul-trend-up'; } else { diffEl.textContent = '0 % vs ' + state.compareYear; diffEl.className = 'ul-kpi-trend'; } } setText('kpi-total-amount', formatEuros(currTotal)); const signDiff = diffEuro >= 0 ? '+' : ''; setText( 'kpi-total-sub', 'En ' + state.compareYear + ' : ' + formatEuros(compTotal) + ' (' + signDiff + formatEuros(diffEuro) + ')' ); const maxReqMonth = getMaxRequiredMonth(state.year); const activeMonthsElapsed = (state.year < state.currentYear) ? 12 : Math.max(1, maxReqMonth); const monthlyAvg = currTotal / activeMonthsElapsed; const avgDue = currStats.duesCount > 0 ? (currTotal / currStats.duesCount) : 0; setText('kpi-monthly-avg', formatEuros(monthlyAvg)); setText('kpi-avg-due', 'Cotisation moy. : ' + formatEuros(avgDue) + ' / mois'); const recoveryRate = activeCount > 0 ? Math.round((upToDateCount / activeCount) * 100) : 0; setText('kpi-recovery-rate', recoveryRate + ' %'); setText('kpi-recovery-sub', upToDateCount + ' à jour sur ' + activeCount + ' syndiqués actifs'); setText('kpi-estimated-arrears', formatEuros(totalEstimatedArrears)); setText('kpi-arrears-sub', totalMissingMonths + ' mois manquant(s) cumulé(s)'); const taxCreditTotal = Math.round(currTotal * 0.66 * 100) / 100; setText('kpi-tax-credit', formatEuros(taxCreditTotal)); setText( 'kpi-dues-count', currStats.duesCount + ' mensualité(s) · ' + currStats.payingMembersCount + ' cotisant(s)' ); // 4. Dessiner les graphiques SVG et le tableau comparatif renderMonthlyComparisonSvg(currStats, compStats); renderMultiYearSvg(availYears); renderMethodsAndStatusCard(currStats, { activeCount, upToDateCount, orangeCount, redCount, }); renderMonthlyComparisonTable(currStats, compStats); } /** * Crée un élément SVG avec attributs. */ function createSvgEl(tag, attrs = {}) { const el = document.createElementNS(SVG_NS, tag); Object.entries(attrs).forEach(([k, v]) => { el.setAttribute(k, String(v)); }); return el; } /** * Graphique SVG principal : Barres mensuelles (Année N vs Année comparée) + Courbes cumulées. */ function renderMonthlyComparisonSvg(currStats, compStats) { const container = document.getElementById('dash-chart-monthly-comparison'); if (!container) { return; } container.textContent = ''; const width = 760; const height = 250; const padLeft = 58; const padRight = 58; const padTop = 20; const padBottom = 34; const plotW = width - padLeft - padRight; const plotH = height - padTop - padBottom; let maxMonthVal = 10; let cumCurr = 0; let cumComp = 0; const cumCurrArr = []; const cumCompArr = []; for (let m = 1; m <= 12; m++) { const vCurr = Number(currStats.monthlyTotals[m]) || 0; const vComp = Number(compStats.monthlyTotals[m]) || 0; if (vCurr > maxMonthVal) { maxMonthVal = vCurr; } if (vComp > maxMonthVal) { maxMonthVal = vComp; } cumCurr += vCurr; cumComp += vComp; cumCurrArr.push(cumCurr); cumCompArr.push(cumComp); } const maxCumVal = Math.max(cumCurr, cumComp, maxMonthVal, 50); const niceMaxMonth = Math.ceil(maxMonthVal * 1.15 / 10) * 10 || 100; const niceMaxCum = Math.ceil(maxCumVal * 1.1 / 50) * 50 || 500; const svg = createSvgEl('svg', { viewBox: `0 0 ${width} ${height}`, class: 'ul-svg-chart', role: 'img', 'aria-label': `Évolution mensuelle ${state.year} vs ${state.compareYear}`, }); // Lignes de grille horizontales (4 paliers) const steps = 4; for (let i = 0; i <= steps; i++) { const ratio = i / steps; const y = padTop + plotH - ratio * plotH; const monthAxisVal = Math.round(niceMaxMonth * ratio); const cumAxisVal = Math.round(niceMaxCum * ratio); svg.appendChild(createSvgEl('line', { x1: padLeft, y1: y, x2: width - padRight, y2: y, stroke: i === 0 ? '#94a3b8' : '#e2e8f0', 'stroke-width': i === 0 ? 1.5 : 1, 'stroke-dasharray': i === 0 ? '' : '3,3', })); // Axe gauche (Mensuel) const lblLeft = createSvgEl('text', { x: padLeft - 6, y: y + 4, 'text-anchor': 'end', fill: '#475569', 'font-size': '10', 'font-weight': '600', }); lblLeft.textContent = monthAxisVal + ' €'; svg.appendChild(lblLeft); // Axe droit (Cumul annuel) const lblRight = createSvgEl('text', { x: width - padRight + 6, y: y + 4, 'text-anchor': 'start', fill: '#991b1b', 'font-size': '10', 'font-weight': '600', }); lblRight.textContent = cumAxisVal + ' €'; svg.appendChild(lblRight); } const groupWidth = plotW / 12; const barWidth = Math.min(16, (groupWidth - 12) / 2); const currCumPoints = []; const compCumPoints = []; for (let m = 1; m <= 12; m++) { const vCurr = Number(currStats.monthlyTotals[m]) || 0; const vComp = Number(compStats.monthlyTotals[m]) || 0; const groupX = padLeft + (m - 1) * groupWidth; const centerX = groupX + groupWidth / 2; // Barre Année comparée (Gris ardoise à gauche) const hComp = Math.max(0, (vComp / niceMaxMonth) * plotH); const rectComp = createSvgEl('rect', { x: centerX - barWidth - 1.5, y: padTop + plotH - hComp, width: barWidth, height: Math.max(hComp, 2), rx: 2, fill: '#94a3b8', class: 'ul-chart-bar', }); const titleComp = createSvgEl('title'); titleComp.textContent = `${MONTH_NAMES[m]} ${state.compareYear} : ${formatEuros(vComp)} (Cumul : ${formatEuros(cumCompArr[m - 1])})`; rectComp.appendChild(titleComp); svg.appendChild(rectComp); // Barre Année active (Rouge CGT à droite) const hCurr = Math.max(0, (vCurr / niceMaxMonth) * plotH); const rectCurr = createSvgEl('rect', { x: centerX + 1.5, y: padTop + plotH - hCurr, width: barWidth, height: Math.max(hCurr, 2), rx: 2, fill: '#d32f2f', class: 'ul-chart-bar', }); const titleCurr = createSvgEl('title'); titleCurr.textContent = `${MONTH_NAMES[m]} ${state.year} : ${formatEuros(vCurr)} (Cumul : ${formatEuros(cumCurrArr[m - 1])})`; rectCurr.appendChild(titleCurr); svg.appendChild(rectCurr); // Étiquette du mois en bas const lblMonth = createSvgEl('text', { x: centerX, y: height - 10, 'text-anchor': 'middle', fill: '#1e293b', 'font-size': '11', 'font-weight': '700', }); lblMonth.textContent = MONTH_SHORT[m]; svg.appendChild(lblMonth); // Coordonnées des courbes de cumul (sur l'axe de droite) const yCumComp = padTop + plotH - (cumCompArr[m - 1] / niceMaxCum) * plotH; const yCumCurr = padTop + plotH - (cumCurrArr[m - 1] / niceMaxCum) * plotH; compCumPoints.push({ x: centerX, y: yCumComp, val: cumCompArr[m - 1], month: m }); currCumPoints.push({ x: centerX, y: yCumCurr, val: cumCurrArr[m - 1], month: m }); } // Courbe cumulée Année comparée (pointillés gris foncé) const compPolyline = compCumPoints.map((p) => `${p.x},${p.y}`).join(' '); svg.appendChild(createSvgEl('polyline', { fill: 'none', stroke: '#475569', 'stroke-width': 2, 'stroke-dasharray': '4,3', points: compPolyline, })); // Courbe cumulée Année active (ligne continue rouge foncé) const currPolyline = currCumPoints.map((p) => `${p.x},${p.y}`).join(' '); svg.appendChild(createSvgEl('polyline', { fill: 'none', stroke: '#991b1b', 'stroke-width': 2.5, points: currPolyline, })); // Points sur la courbe cumulée active currCumPoints.forEach((p) => { const dot = createSvgEl('circle', { cx: p.x, cy: p.y, r: 3.5, fill: '#ffffff', stroke: '#991b1b', 'stroke-width': 2, }); const t = createSvgEl('title'); t.textContent = `Cumul ${MONTH_NAMES[p.month]} ${state.year} : ${formatEuros(p.val)}`; dot.appendChild(t); svg.appendChild(dot); }); container.appendChild(svg); } /** * Graphique SVG : Comparaison pluriannuelle (Barres par année cliquables). */ function renderMultiYearSvg(availYears) { const container = document.getElementById('dash-chart-multiyear'); if (!container) { return; } container.textContent = ''; const sortedYears = [...availYears].map(Number).sort((a, b) => a - b); const width = 380; const height = 128; const padLeft = 14; const padRight = 14; const padTop = 22; const padBottom = 26; const plotW = width - padLeft - padRight; const plotH = height - padTop - padBottom; let maxYearTotal = 50; sortedYears.forEach((y) => { const st = getYearStatsObject(y); if (st.totalAmount > maxYearTotal) { maxYearTotal = st.totalAmount; } }); const niceMax = maxYearTotal * 1.18; const svg = createSvgEl('svg', { viewBox: `0 0 ${width} ${height}`, class: 'ul-svg-chart', role: 'img', 'aria-label': 'Comparaison pluriannuelle des cotisations', }); const colW = plotW / Math.max(1, sortedYears.length); const barW = Math.min(44, colW * 0.58); sortedYears.forEach((y, idx) => { const st = getYearStatsObject(y); const val = Number(st.totalAmount) || 0; const h = Math.max(3, (val / niceMax) * plotH); const centerX = padLeft + idx * colW + colW / 2; const x = centerX - barW / 2; const barY = padTop + plotH - h; const isCurrent = y === state.year; const isCompare = y === state.compareYear; const g = createSvgEl('g', { class: 'ul-multiyear-bar-group', 'data-year': String(y), }); const rect = createSvgEl('rect', { x: x, y: barY, width: barW, height: h, rx: 4, fill: isCurrent ? '#d32f2f' : (isCompare ? '#475569' : '#cbd5e1'), }); const title = createSvgEl('title'); title.textContent = `Année ${y} : ${formatEuros(val)} (${st.payingMembersCount} cotisants, ${st.duesCount} versements) — Cliquer pour ouvrir ${y}`; rect.appendChild(title); g.appendChild(rect); const valLbl = createSvgEl('text', { x: centerX, y: barY - 5, 'text-anchor': 'middle', fill: isCurrent ? '#991b1b' : '#334155', 'font-size': '10', 'font-weight': '700', }); valLbl.textContent = formatShortEuros(val); g.appendChild(valLbl); const yearLbl = createSvgEl('text', { x: centerX, y: height - 7, 'text-anchor': 'middle', fill: isCurrent ? '#d32f2f' : '#1e293b', 'font-size': '11.5', 'font-weight': isCurrent ? '800' : '600', }); yearLbl.textContent = String(y); g.appendChild(yearLbl); svg.appendChild(g); }); container.appendChild(svg); } /** * Carte Modes de règlement & Répartition des statuts des syndiqués actifs. */ function renderMethodsAndStatusCard(currStats, counts) { const container = document.getElementById('dash-chart-methods-status'); if (!container) { return; } container.textContent = ''; const totalAmount = Number(currStats.totalAmount) || 0; const methods = ['Virement', 'Prélèvement', 'Chèque', 'Espèces']; const methodsBox = document.createElement('div'); methodsBox.className = 'ul-methods-breakdown'; methods.forEach((method) => { const amt = (currStats.byMethod && Number(currStats.byMethod[method])) || 0; const pct = totalAmount > 0 ? Math.round((amt / totalAmount) * 100) : 0; const row = document.createElement('div'); row.className = 'ul-method-stat-row'; const head = document.createElement('div'); head.className = 'ul-method-stat-head'; const nameSpan = document.createElement('span'); nameSpan.className = 'ul-method-stat-name'; const dot = document.createElement('span'); dot.className = 'ul-method-color-dot'; dot.style.backgroundColor = METHOD_COLORS[method] || '#15803d'; nameSpan.appendChild(dot); nameSpan.appendChild(document.createTextNode(method)); const valSpan = document.createElement('strong'); valSpan.textContent = formatEuros(amt) + ' (' + pct + ' %)'; head.appendChild(nameSpan); head.appendChild(valSpan); const barTrack = document.createElement('div'); barTrack.className = 'ul-progress-track'; const barFill = document.createElement('div'); barFill.className = 'ul-progress-fill'; barFill.style.width = pct + '%'; barFill.style.backgroundColor = METHOD_COLORS[method] || '#15803d'; barTrack.appendChild(barFill); row.appendChild(head); row.appendChild(barTrack); methodsBox.appendChild(row); }); // Barre empilée de statut des syndiqués actifs const statusBox = document.createElement('div'); statusBox.className = 'ul-status-breakdown'; const statusTitle = document.createElement('div'); statusTitle.className = 'ul-status-breakdown-title'; statusTitle.textContent = 'État des ' + counts.activeCount + ' syndiqués actifs (' + state.year + ')'; statusBox.appendChild(statusTitle); const stackedBar = document.createElement('div'); stackedBar.className = 'ul-stacked-status-bar'; const totalActive = Math.max(1, counts.activeCount); const segments = [ { label: 'À jour', count: counts.upToDateCount, color: '#15803d' }, { label: 'Pas à jour', count: counts.orangeCount, color: '#d97706' }, { label: '0 € versé', count: counts.redCount, color: '#dc2626' }, ]; segments.forEach((seg) => { if (seg.count > 0) { const segDiv = document.createElement('div'); segDiv.className = 'ul-stacked-segment'; segDiv.style.width = ((seg.count / totalActive) * 100).toFixed(1) + '%'; segDiv.style.backgroundColor = seg.color; segDiv.title = seg.label + ' : ' + seg.count; stackedBar.appendChild(segDiv); } }); statusBox.appendChild(stackedBar); const pillsWrap = document.createElement('div'); pillsWrap.className = 'ul-status-mini-pills'; segments.forEach((seg) => { const p = document.createElement('span'); p.className = 'ul-status-mini-pill'; p.textContent = seg.label + ' : ' + seg.count; pillsWrap.appendChild(p); }); statusBox.appendChild(pillsWrap); container.appendChild(methodsBox); container.appendChild(statusBox); } /** * Tableau comparatif mensuel synthétique (Ligne Année N, Ligne Année comparée, Ligne Écart). */ function renderMonthlyComparisonTable(currStats, compStats) { const tbody = document.getElementById('dash-comp-tbody'); if (!tbody) { return; } tbody.textContent = ''; // Ligne 1 : Année active const trCurr = document.createElement('tr'); trCurr.className = 'ul-comp-row-curr'; const thCurr = document.createElement('th'); thCurr.textContent = 'Année ' + state.year; trCurr.appendChild(thCurr); for (let m = 1; m <= 12; m++) { const td = document.createElement('td'); td.textContent = formatShortEuros(currStats.monthlyTotals[m] || 0); trCurr.appendChild(td); } const tdTotalCurr = document.createElement('td'); tdTotalCurr.className = 'ul-comp-col-total'; tdTotalCurr.textContent = formatEuros(currStats.totalAmount || 0); trCurr.appendChild(tdTotalCurr); tbody.appendChild(trCurr); // Ligne 2 : Année de comparaison const trComp = document.createElement('tr'); trComp.className = 'ul-comp-row-prev'; const thComp = document.createElement('th'); thComp.textContent = 'Année ' + state.compareYear; trComp.appendChild(thComp); for (let m = 1; m <= 12; m++) { const td = document.createElement('td'); td.textContent = formatShortEuros(compStats.monthlyTotals[m] || 0); trComp.appendChild(td); } const tdTotalComp = document.createElement('td'); tdTotalComp.className = 'ul-comp-col-total'; tdTotalComp.textContent = formatEuros(compStats.totalAmount || 0); trComp.appendChild(tdTotalComp); tbody.appendChild(trComp); // Ligne 3 : Écart mensuel (€) const trDiff = document.createElement('tr'); trDiff.className = 'ul-comp-row-diff'; const thDiff = document.createElement('th'); thDiff.textContent = 'Écart (' + state.year + ' - ' + state.compareYear + ')'; trDiff.appendChild(thDiff); for (let m = 1; m <= 12; m++) { const diff = (Number(currStats.monthlyTotals[m]) || 0) - (Number(compStats.monthlyTotals[m]) || 0); const td = document.createElement('td'); const sign = diff > 0 ? '+' : ''; td.textContent = sign + formatShortEuros(diff); td.className = diff > 0 ? 'ul-diff-pos' : (diff < 0 ? 'ul-diff-neg' : 'ul-diff-zero'); trDiff.appendChild(td); } const totalDiff = (Number(currStats.totalAmount) || 0) - (Number(compStats.totalAmount) || 0); const tdTotalDiff = document.createElement('td'); const totalSign = totalDiff > 0 ? '+' : ''; tdTotalDiff.textContent = totalSign + formatEuros(totalDiff); tdTotalDiff.className = 'ul-comp-col-total ' + (totalDiff >= 0 ? 'ul-diff-pos' : 'ul-diff-neg'); trDiff.appendChild(tdTotalDiff); tbody.appendChild(trDiff); } /* ====================================================================== * SAISIE DE COTISATIONS & GESTION DES FICHES SYNDIQUÉS * ====================================================================== */ /** * Détermine le pré-remplissage intelligent pour un syndiqué et un mois cliqué. */ function computePrefillForCell(member, month) { const memberDues = state.dues[member.id] || {}; const existingDue = memberDues[month]; if (existingDue && Number(existingDue.amount) > 0) { return { isExisting: true, amount: Number(existingDue.amount).toFixed(2), method: existingDue.payment_method || 'Virement', note: existingDue.note || '', hint: 'Modification de la cotisation existante de ' + MONTH_NAMES[month] + ' ' + state.year + '.', }; } // 1. Chercher le mois précédent M-1, ou en remontant vers Janvier for (let prevM = month - 1; prevM >= 1; prevM--) { const d = memberDues[prevM]; if (d && Number(d.amount) > 0) { return { isExisting: false, amount: Number(d.amount).toFixed(2), method: d.payment_method || member.default_method || 'Virement', note: '', hint: 'Pré-rempli automatiquement d\'après ' + MONTH_NAMES[prevM] + ' (' + formatEuros(d.amount) + ' — ' + d.payment_method + '). Appuyez sur Entrée pour valider.', }; } } // 2. À défaut, chercher n'importe quel autre mois payé dans l'année for (let m = 12; m >= 1; m--) { const d = memberDues[m]; if (d && Number(d.amount) > 0) { return { isExisting: false, amount: Number(d.amount).toFixed(2), method: d.payment_method || member.default_method || 'Virement', note: '', hint: 'Pré-rempli d\'après ' + MONTH_NAMES[m] + ' (' + formatEuros(d.amount) + ' — ' + d.payment_method + '). Appuyez sur Entrée pour valider.', }; } } // 3. À défaut, utiliser default_amount et default_method de la fiche du syndiqué const fallbackAmount = Number(member.default_amount) > 0 ? Number(member.default_amount).toFixed(2) : ''; const fallbackMethod = member.default_method || 'Virement'; return { isExisting: false, amount: fallbackAmount, method: fallbackMethod, note: '', hint: fallbackAmount !== '' ? 'Pré-rempli avec la cotisation habituelle du syndiqué (' + formatEuros(fallbackAmount) + ' — ' + fallbackMethod + '). Appuyez sur Entrée pour valider.' : 'Première saisie pour ce syndiqué : indiquez le montant mensuel puis appuyez sur Entrée.', }; } /** * Ouvre la fenêtre modale légère de saisie de cotisation au simple clic sur une cellule. */ function openDueModal(memberId, month) { const member = state.members.find((m) => Number(m.id) === Number(memberId)); if (!member) { return; } const prefill = computePrefillForCell(member, month); const modal = document.getElementById('due-modal'); const subtitle = document.getElementById('due-modal-subtitle'); const hintEl = document.getElementById('due-modal-prefill-hint'); const inputMemberId = document.getElementById('due-member-id'); const inputYear = document.getElementById('due-year'); const inputMonthStart = document.getElementById('due-month-start'); const inputAmount = document.getElementById('due-input-amount'); const inputMethod = document.getElementById('due-input-method'); const selectMonthEnd = document.getElementById('due-input-month-end'); const inputNote = document.getElementById('due-input-note'); const btnDelete = document.getElementById('btn-delete-due'); subtitle.textContent = (member.civility ? member.civility + ' ' : '') + member.last_name + ' ' + member.first_name + ' — ' + MONTH_NAMES[month] + ' ' + state.year; hintEl.textContent = prefill.hint; inputMemberId.value = String(member.id); inputYear.value = String(state.year); inputMonthStart.value = String(month); inputAmount.value = prefill.amount; inputMethod.value = prefill.method; inputNote.value = prefill.note; // Remplir le sélecteur d'extension multi-mois (de month à 12) selectMonthEnd.textContent = ''; for (let m = month; m <= 12; m++) { const opt = document.createElement('option'); opt.value = String(m); opt.textContent = m === month ? (MONTH_NAMES[m] + ' uniquement (1 mois)') : ('Jusqu\'à ' + MONTH_NAMES[m] + ' inclus (' + (m - month + 1) + ' mois)'); selectMonthEnd.appendChild(opt); } selectMonthEnd.value = String(month); if (prefill.isExisting) { btnDelete.classList.remove('hidden'); } else { btnDelete.classList.add('hidden'); } modal.classList.remove('hidden'); // Focus immédiat et sélection du montant pour validation directe avec Entrée requestAnimationFrame(() => { inputAmount.focus(); inputAmount.select(); }); } function closeDueModal() { const modal = document.getElementById('due-modal'); if (modal) { modal.classList.add('hidden'); } } /** * Enregistre la cotisation (1 ou plusieurs mois) via l'API. */ async function handleDueFormSubmit(event) { event.preventDefault(); const memberId = Number(document.getElementById('due-member-id').value); const year = Number(document.getElementById('due-year').value); const monthStart = Number(document.getElementById('due-month-start').value); const monthEnd = Number(document.getElementById('due-input-month-end').value); const amount = parseFloat(document.getElementById('due-input-amount').value); const paymentMethod = document.getElementById('due-input-method').value; const note = document.getElementById('due-input-note').value; if (isNaN(amount) || amount <= 0) { showToast('Veuillez saisir un montant valide supérieur à 0,00 €.', 'error'); return; } try { const res = await apiFetch('/api/dues', { method: 'POST', body: JSON.stringify({ member_id: memberId, year: year, month_start: monthStart, month_end: monthEnd, amount: amount, payment_method: paymentMethod, note: note, }), }); if (!state.dues[memberId]) { state.dues[memberId] = {}; } Object.entries(res.dues || {}).forEach(([m, dueObj]) => { state.dues[memberId][Number(m)] = dueObj; }); if (res.multiYearStats) { state.multiYearStats = res.multiYearStats; } // Mettre à jour les valeurs de référence locales du syndiqué const member = state.members.find((m) => Number(m.id) === memberId); if (member) { member.default_amount = amount; member.default_method = paymentMethod; } closeDueModal(); renderAll(); const countMonths = monthEnd - monthStart + 1; showToast( countMonths > 1 ? ('Cotisations enregistrées (' + countMonths + ' mois : ' + MONTH_SHORT[monthStart] + ' → ' + MONTH_SHORT[monthEnd] + ').') : ('Cotisation de ' + MONTH_NAMES[monthStart] + ' enregistrée (' + formatEuros(amount) + ').') ); } catch (err) { showToast(err.message, 'error'); } } /** * Efface la cotisation du mois sélectionné. */ async function handleDeleteDue() { const memberId = Number(document.getElementById('due-member-id').value); const year = Number(document.getElementById('due-year').value); const month = Number(document.getElementById('due-month-start').value); try { const res = await apiFetch('/api/dues/delete', { method: 'POST', body: JSON.stringify({ member_id: memberId, year: year, month: month, }), }); if (state.dues[memberId]) { delete state.dues[memberId][month]; } if (res && res.multiYearStats) { state.multiYearStats = res.multiYearStats; } closeDueModal(); renderAll(); showToast('Cotisation de ' + MONTH_NAMES[month] + ' effacée.'); } catch (err) { showToast(err.message, 'error'); } } /** * Ouvre la modale d'ajout ou d'édition d'une fiche syndiqué. */ function openMemberModal(memberId = null) { const modal = document.getElementById('member-modal'); const title = document.getElementById('member-modal-title'); const btnDelete = document.getElementById('btn-delete-member'); const member = memberId !== null ? state.members.find((m) => Number(m.id) === Number(memberId)) : null; document.getElementById('mbr-id').value = member ? String(member.id) : ''; document.getElementById('mbr-civility').value = member ? (member.civility || '') : 'M.'; document.getElementById('mbr-last-name').value = member ? member.last_name : ''; document.getElementById('mbr-first-name').value = member ? member.first_name : ''; document.getElementById('mbr-cogitiel-id').value = member ? member.cogitiel_id : ''; document.getElementById('mbr-default-amount').value = member ? Number(member.default_amount).toFixed(2) : '0.00'; document.getElementById('mbr-default-method').value = member ? (member.default_method || 'Virement') : 'Virement'; document.getElementById('mbr-email').value = member ? (member.email || '') : ''; document.getElementById('mbr-phone-personal').value = member ? (member.phone_personal || '') : ''; document.getElementById('mbr-phone-pro').value = member ? (member.phone_pro || '') : ''; document.getElementById('mbr-address').value = member ? (member.address || '') : ''; document.getElementById('mbr-postal-code').value = member ? (member.postal_code || '') : ''; document.getElementById('mbr-city').value = member ? (member.city || '') : ''; document.getElementById('mbr-base-code').value = member ? (member.base_code || '90045110520251341') : '90045110520251341'; document.getElementById('mbr-base-name').value = member ? (member.base_name || 'INDIVIDUELS UL FOIX HAUTE-ARIEGE') : 'INDIVIDUELS UL FOIX HAUTE-ARIEGE'; document.getElementById('mbr-is-archived').checked = member ? Boolean(Number(member.is_archived)) : false; if (member) { title.textContent = 'Modifier la fiche : ' + member.last_name + ' ' + member.first_name; btnDelete.classList.remove('hidden'); } else { title.textContent = 'Nouveau syndiqué (UL CGT Foix Haute-Ariège)'; btnDelete.classList.add('hidden'); } modal.classList.remove('hidden'); requestAnimationFrame(() => { document.getElementById('mbr-last-name').focus(); }); } function closeMemberModal() { const modal = document.getElementById('member-modal'); if (modal) { modal.classList.add('hidden'); } } async function handleMemberFormSubmit(event) { event.preventDefault(); const idVal = document.getElementById('mbr-id').value; const payload = { cogitiel_id: document.getElementById('mbr-cogitiel-id').value.trim(), civility: document.getElementById('mbr-civility').value, last_name: document.getElementById('mbr-last-name').value.trim().toUpperCase(), first_name: document.getElementById('mbr-first-name').value.trim(), default_amount: parseFloat(document.getElementById('mbr-default-amount').value) || 0, default_method: document.getElementById('mbr-default-method').value, email: document.getElementById('mbr-email').value.trim(), phone_personal: document.getElementById('mbr-phone-personal').value.trim(), phone_pro: document.getElementById('mbr-phone-pro').value.trim(), address: document.getElementById('mbr-address').value.trim(), postal_code: document.getElementById('mbr-postal-code').value.trim(), city: document.getElementById('mbr-city').value.trim().toUpperCase(), base_code: document.getElementById('mbr-base-code').value.trim(), base_name: document.getElementById('mbr-base-name').value.trim(), is_archived: document.getElementById('mbr-is-archived').checked ? 1 : 0, }; try { if (idVal !== '') { const res = await apiFetch('/api/members/' + encodeURIComponent(idVal), { method: 'PUT', body: JSON.stringify(payload), }); const idx = state.members.findIndex((m) => Number(m.id) === Number(idVal)); if (idx !== -1 && res.member) { state.members[idx] = res.member; } showToast('Fiche syndiqué mise à jour.'); } else { const res = await apiFetch('/api/members', { method: 'POST', body: JSON.stringify(payload), }); if (res.member) { state.members.push(res.member); } showToast('Nouveau syndiqué ajouté avec succès.'); } state.members.sort((a, b) => { const cmp = a.last_name.localeCompare(b.last_name, 'fr'); return cmp !== 0 ? cmp : a.first_name.localeCompare(b.first_name, 'fr'); }); closeMemberModal(); renderAll(); } catch (err) { showToast(err.message, 'error'); } } async function handleDeleteMember() { const idVal = document.getElementById('mbr-id').value; if (!idVal) { return; } const member = state.members.find((m) => Number(m.id) === Number(idVal)); const label = member ? (member.last_name + ' ' + member.first_name) : ('#' + idVal); if (!window.confirm('Confirmer la suppression définitive de ' + label + ' et de son historique de cotisations ?')) { return; } try { const res = await apiFetch('/api/members/' + encodeURIComponent(idVal), { method: 'DELETE', }); state.members = state.members.filter((m) => Number(m.id) !== Number(idVal)); delete state.dues[Number(idVal)]; if (res && res.multiYearStats) { state.multiYearStats = res.multiYearStats; } closeMemberModal(); renderAll(); showToast('Syndiqué supprimé.'); } catch (err) { showToast(err.message, 'error'); } } /** * Gère l'importation d'un fichier d'export CoGétise (.asp / CSV Windows-1252). */ async function handleImportFileChange(event) { const fileInput = event.target; if (!fileInput.files || fileInput.files.length === 0) { return; } const file = fileInput.files[0]; const formData = new FormData(); formData.append('file', file); try { showToast('Importation du fichier ' + file.name + ' en cours…', 'info'); const summary = await apiFetch('/api/import', { method: 'POST', body: formData, }); await loadState(state.year); showToast( 'Import CoGétise réussi : ' + summary.inserted + ' créé(s), ' + summary.updated + ' mis à jour (Total traité : ' + summary.total + ').', 'success' ); } catch (err) { showToast('Erreur d\'importation : ' + err.message, 'error'); } finally { fileInput.value = ''; } } /* ====================================================================== * OUTIL DE PUBLIPOSTAGE & MODÈLES PERSONNALISABLES * ====================================================================== */ /** * Charge le modèle choisi dans l'éditeur et applique la règle de sélection automatique des destinataires. */ function syncTemplateEditorFromSlug(slug, autoSelectRecipients = true) { state.activeTemplateSlug = slug; const selectEl = document.getElementById('select-template'); if (selectEl && selectEl.value !== slug) { selectEl.value = slug; } const tpl = state.templates.find((t) => t.slug === slug); if (tpl) { document.getElementById('tpl-title').value = tpl.title || ''; document.getElementById('tpl-subject').value = tpl.subject || ''; document.getElementById('tpl-body').value = tpl.body || ''; } const ruleHints = { tax_receipt: 'Sélection automatique : tous les syndiqués actifs ayant versé plus de 0,00 € sur l\'année.', missing_months: 'Sélection automatique : tous les syndiqués actifs en Alerte Orange (cotisations pas à jour).', annual_zero_warning: 'Sélection automatique : tous les syndiqués actifs en Alerte Rouge (0,00 € versé sur l\'année).', }; setText('mailing-auto-rule-hint', ruleHints[slug] || ''); if (autoSelectRecipients) { applyAutoRecipientSelection(slug); } renderMailingRecipients(); renderMailingPreview(); } /** * Coche automatiquement les destinataires correspondant au modèle actif. */ function applyAutoRecipientSelection(slug) { state.selectedRecipientIds.clear(); state.members.forEach((member) => { if (Number(member.is_archived) === 1) { return; } if (state.mailingNoEmailOnly && member.email && member.email.trim() !== '') { return; } const metrics = computeMemberMetrics(member); if (slug === 'tax_receipt' && metrics.totalPaid > 0) { state.selectedRecipientIds.add(Number(member.id)); } else if (slug === 'missing_months' && metrics.alertLevel === 'orange') { state.selectedRecipientIds.add(Number(member.id)); } else if (slug === 'annual_zero_warning' && metrics.alertLevel === 'red') { state.selectedRecipientIds.add(Number(member.id)); } }); const firstSelected = Array.from(state.selectedRecipientIds)[0] || null; state.previewMemberId = firstSelected; } function getEligibleMailingMembers() { return state.members.filter((member) => { if (Number(member.is_archived) === 1) { return false; } if (state.mailingNoEmailOnly && member.email && member.email.trim() !== '') { return false; } return true; }); } /** * Affiche la liste des destinataires dans l'onglet Publipostage. */ function renderMailingRecipients() { const listEl = document.getElementById('mailing-recipients-list'); const previewSelect = document.getElementById('select-preview-member'); if (!listEl || !previewSelect) { return; } listEl.textContent = ''; previewSelect.textContent = ''; const eligibleMembers = getEligibleMailingMembers(); let emailCapableSelectedCount = 0; eligibleMembers.forEach((member) => { const id = Number(member.id); const isChecked = state.selectedRecipientIds.has(id); const hasEmail = Boolean(member.email && member.email.includes('@')); if (isChecked && hasEmail) { emailCapableSelectedCount++; } const metrics = computeMemberMetrics(member); const item = document.createElement('label'); item.className = 'ul-recipient-item' + (isChecked ? ' selected' : ''); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.dataset.action = 'toggle-recipient'; checkbox.dataset.memberId = String(id); checkbox.checked = isChecked; const infoDiv = document.createElement('div'); infoDiv.className = 'ul-recipient-info'; const nameLine = document.createElement('div'); nameLine.className = 'ul-recipient-name'; nameLine.textContent = member.last_name + ' ' + member.first_name; const metaLine = document.createElement('div'); metaLine.className = 'ul-recipient-meta'; metaLine.textContent = (hasEmail ? ('📧 ' + member.email) : '📮 Courrier papier (sans e-mail)') + ' · Payé : ' + formatEuros(metrics.totalPaid); infoDiv.appendChild(nameLine); infoDiv.appendChild(metaLine); const badgeSpan = document.createElement('span'); if (metrics.alertLevel === 'red') { badgeSpan.className = 'ul-badge ul-badge-red'; badgeSpan.textContent = '0,00 €'; } else if (metrics.alertLevel === 'orange') { badgeSpan.className = 'ul-badge ul-badge-orange'; badgeSpan.textContent = '-' + metrics.missingMonths.length + ' m.'; } else { badgeSpan.className = 'ul-badge ul-badge-green'; badgeSpan.textContent = formatEuros(metrics.totalPaid); } item.appendChild(checkbox); item.appendChild(infoDiv); item.appendChild(badgeSpan); listEl.appendChild(item); // Ajouter au sélecteur de prévisualisation const opt = document.createElement('option'); opt.value = String(id); opt.textContent = (isChecked ? '✓ ' : '') + member.last_name + ' ' + member.first_name + ' (' + formatEuros(metrics.totalPaid) + ')'; previewSelect.appendChild(opt); }); if (state.previewMemberId && eligibleMembers.some((m) => Number(m.id) === Number(state.previewMemberId))) { previewSelect.value = String(state.previewMemberId); } else if (eligibleMembers.length > 0) { state.previewMemberId = Number(eligibleMembers[0].id); previewSelect.value = String(state.previewMemberId); } else { state.previewMemberId = null; } const totalSelected = state.selectedRecipientIds.size; setText('mailing-selected-count', String(totalSelected)); setText('btn-print-count', String(totalSelected)); setText('btn-email-count', String(emailCapableSelectedCount)); } /** * Remplace toutes les balises dynamiques {{...}} côté client pour l'aperçu et l'impression PDF. */ function replaceVariablesForMember(templateText, member) { const metrics = computeMemberMetrics(member); const now = new Date(); const dateDuJour = String(now.getDate()).padStart(2, '0') + '/' + String(now.getMonth() + 1).padStart(2, '0') + '/' + now.getFullYear(); const addrParts = []; if (member.address && member.address.trim() !== '') { addrParts.push(member.address.trim()); } const cpVille = ((member.postal_code || '') + ' ' + (member.city || '')).trim(); if (cpVille !== '') { addrParts.push(cpVille); } const adresseComplete = addrParts.length > 0 ? addrParts.join(' — ') : 'Adresse non renseignée'; const map = { '{{civilite}}': member.civility || '', '{{nom}}': member.last_name || '', '{{prenom}}': member.first_name || '', '{{adresse_complete}}': adresseComplete, '{{code_responsable}}': member.cogitiel_id || '', '{{annee}}': String(state.year), '{{date_du_jour}}': dateDuJour, '{{total_paye}}': formatEuros(metrics.totalPaid), '{{credit_impot}}': formatEuros(metrics.creditImpot), '{{nb_mois_payes}}': String(metrics.paidMonthsCount), '{{mois_manquants_liste}}': metrics.missingMonthsNames.length > 0 ? metrics.missingMonthsNames.join(', ') : 'Aucun', '{{nb_mois_manquants}}': String(metrics.missingMonths.length), '{{cotisation_habituelle}}': formatEuros(metrics.usualAmount), '{{montant_estime_retard}}': formatEuros(metrics.estimatedArrears), }; let output = templateText || ''; Object.entries(map).forEach(([tag, val]) => { output = output.split(tag).join(val); }); return output; } /** * Construit un élément DOM représentant une page de courrier A4 avec la vraie bannière officielle UL CGT Foix Haute-Ariège. */ function createLetterDomElement(member, subjectText, bodyText) { const renderedSubject = replaceVariablesForMember(subjectText, member); const renderedBody = replaceVariablesForMember(bodyText, member); const page = document.createElement('article'); page.className = 'ul-print-page'; const header = document.createElement('header'); header.className = 'ul-letter-header'; if (state.bannerUrl) { const bannerImg = document.createElement('img'); bannerImg.src = state.bannerUrl; bannerImg.alt = 'Union Locale CGT Foix Haute-Ariège'; bannerImg.className = 'ul-letter-banner-img'; bannerImg.width = 360; bannerImg.height = 72; header.appendChild(bannerImg); } else if (state.logoUrl) { const logoImg = document.createElement('img'); logoImg.src = state.logoUrl; logoImg.alt = 'CGT'; logoImg.className = 'ul-letter-logo-img'; logoImg.width = 40; logoImg.height = 52; header.appendChild(logoImg); } const orgSub = document.createElement('div'); orgSub.className = 'ul-letter-org-sub'; orgSub.textContent = 'Trésorerie — Collectif des Isolés / Multipros · Bourse du Travail, 09000 FOIX'; header.appendChild(orgSub); page.appendChild(header); const subjectBar = document.createElement('div'); subjectBar.className = 'ul-letter-subject'; subjectBar.textContent = renderedSubject; page.appendChild(subjectBar); const bodyDiv = document.createElement('div'); bodyDiv.className = 'ul-letter-body'; bodyDiv.textContent = renderedBody; page.appendChild(bodyDiv); return page; } /** * Met à jour l'aperçu en direct du courrier dans l'onglet Publipostage. */ function renderMailingPreview() { const previewContainer = document.getElementById('mailing-live-preview'); if (!previewContainer) { return; } previewContainer.textContent = ''; const member = state.members.find((m) => Number(m.id) === Number(state.previewMemberId)); if (!member) { previewContainer.textContent = 'Sélectionnez un syndiqué pour prévisualiser son courrier personnalisé.'; return; } const subjectText = document.getElementById('tpl-subject').value; const bodyText = document.getElementById('tpl-body').value; const letterEl = createLetterDomElement(member, subjectText, bodyText); previewContainer.appendChild(letterEl); } /** * Sauvegarde le modèle de courrier en base de données. */ async function handleSaveTemplate() { const slug = state.activeTemplateSlug; const title = document.getElementById('tpl-title').value.trim(); const subject = document.getElementById('tpl-subject').value.trim(); const body = document.getElementById('tpl-body').value; if (!title || !subject || !body) { showToast('Le titre, l\'objet et le corps du modèle sont requis.', 'error'); return; } try { const res = await apiFetch('/api/templates/' + encodeURIComponent(slug), { method: 'PUT', body: JSON.stringify({ title, subject, body }), }); const idx = state.templates.findIndex((t) => t.slug === slug); if (idx !== -1 && res.template) { state.templates[idx] = res.template; } showToast('Modèle « ' + title + ' » enregistré en base de données.'); } catch (err) { showToast(err.message, 'error'); } } /** * Réinitialise les 3 modèles par défaut en base de données. */ async function handleResetTemplates() { if (!window.confirm('Restaurer le texte original des 3 modèles de courriers ?')) { return; } try { const res = await apiFetch('/api/templates/reset', { method: 'POST', }); state.templates = res.templates || []; syncTemplateEditorFromSlug(state.activeTemplateSlug, false); showToast('Les 3 modèles ont été restaurés.'); } catch (err) { showToast(err.message, 'error'); } } /** * Génère les pages A4 pour tous les destinataires cochés et lance l'impression / export PDF. */ function handlePrintMailing() { const selectedMembers = state.members.filter((m) => state.selectedRecipientIds.has(Number(m.id))); if (selectedMembers.length === 0) { showToast('Veuillez cocher au moins un destinataire à imprimer.', 'error'); return; } const subjectText = document.getElementById('tpl-subject').value; const bodyText = document.getElementById('tpl-body').value; const printContainer = document.getElementById('ul-print-container'); printContainer.textContent = ''; selectedMembers.forEach((member) => { const pageEl = createLetterDomElement(member, subjectText, bodyText); printContainer.appendChild(pageEl); }); window.print(); } /** * Envoie les courriers personnalisés par e-mail via le service OCP\Mail\IMailer de Nextcloud. */ async function handleEmailMailing() { const selectedIds = Array.from(state.selectedRecipientIds); if (selectedIds.length === 0) { showToast('Veuillez sélectionner au moins un destinataire.', 'error'); return; } const subject = document.getElementById('tpl-subject').value.trim(); const body = document.getElementById('tpl-body').value; try { showToast('Envoi des courriers électroniques en cours…', 'info'); const res = await apiFetch('/api/mailing/send', { method: 'POST', body: JSON.stringify({ year: state.year, member_ids: selectedIds, subject: subject, body: body, }), }); let msg = 'Envoi terminé : ' + res.sent + ' e-mail(s) envoyé(s)'; if (res.skipped_no_email > 0) { msg += ', ' + res.skipped_no_email + ' ignoré(s) (sans adresse e-mail)'; } if (res.errors && res.errors.length > 0) { msg += ', ' + res.errors.length + ' erreur(s) (' + res.errors[0] + ')'; showToast(msg, 'error'); } else { showToast(msg + '.', 'success'); } } catch (err) { showToast('Erreur d\'envoi : ' + err.message, 'error'); } } /** * Insère une balise {{variable}} à la position du curseur dans l'éditeur de modèle. */ function insertVariableAtCursor(variableTag) { const target = state.lastFocusedTemplateField || document.getElementById('tpl-body'); if (!target) { return; } const start = target.selectionStart || 0; const end = target.selectionEnd || 0; const currentValue = target.value; target.value = currentValue.substring(0, start) + variableTag + currentValue.substring(end); target.focus(); const newPos = start + variableTag.length; target.setSelectionRange(newPos, newPos); renderMailingPreview(); } /* ====================================================================== * GESTION DES PERMISSIONS D'ACCÈS (#view-permissions — Administrateurs) * ====================================================================== */ async function loadPermissions() { if (!state.isAdmin) { return; } try { const data = await apiFetch('/api/permissions'); state.permissionsLoaded = true; state.permissions.accessMode = data.access_mode === 'all_users' ? 'all_users' : 'restricted'; state.permissions.allowedGroups = new Set(Array.isArray(data.allowed_groups) ? data.allowed_groups : []); state.permissions.allowedUsers = new Set(Array.isArray(data.allowed_users) ? data.allowed_users : []); state.permissions.availableGroups = Array.isArray(data.available_groups) ? data.available_groups : []; state.permissions.availableUsers = Array.isArray(data.available_users) ? data.available_users : []; renderPermissions(); } catch (err) { showToast('Erreur chargement des permissions : ' + err.message, 'error'); } } function renderPermissions() { if (!state.isAdmin) { return; } const radioRestricted = document.getElementById('perm-mode-restricted'); const radioAll = document.getElementById('perm-mode-all'); const gridEl = document.getElementById('perm-selectors-grid'); if (radioRestricted && radioAll) { radioRestricted.checked = state.permissions.accessMode === 'restricted'; radioAll.checked = state.permissions.accessMode === 'all_users'; } if (gridEl) { gridEl.classList.toggle('ul-perm-disabled-look', state.permissions.accessMode === 'all_users'); } // Rendu de la liste des groupes const groupsList = document.getElementById('perm-groups-list'); if (groupsList) { groupsList.textContent = ''; const qG = state.permissions.groupSearch.trim().toLowerCase(); const filteredGroups = state.permissions.availableGroups.filter((g) => { if (qG === '') { return true; } return (g.id + ' ' + g.displayName).toLowerCase().includes(qG); }); if (filteredGroups.length === 0) { const empty = document.createElement('div'); empty.className = 'ul-perm-empty'; empty.textContent = 'Aucun groupe Nextcloud trouvé.'; groupsList.appendChild(empty); } else { filteredGroups.forEach((group) => { const isAdminGroup = group.id === 'admin'; const isChecked = isAdminGroup || state.permissions.allowedGroups.has(group.id); const lbl = document.createElement('label'); lbl.className = 'ul-perm-item' + (isChecked ? ' selected' : ''); const chk = document.createElement('input'); chk.type = 'checkbox'; chk.dataset.permGroup = group.id; chk.checked = isChecked; if (isAdminGroup) { chk.disabled = true; } const info = document.createElement('div'); info.className = 'ul-perm-item-info'; const title = document.createElement('div'); title.className = 'ul-perm-item-title'; title.textContent = group.displayName || group.id; const sub = document.createElement('div'); sub.className = 'ul-perm-item-sub'; sub.textContent = 'ID groupe : ' + group.id; info.appendChild(title); info.appendChild(sub); lbl.appendChild(chk); lbl.appendChild(info); if (isAdminGroup) { const badge = document.createElement('span'); badge.className = 'ul-badge ul-badge-green'; badge.textContent = 'Accès permanent'; lbl.appendChild(badge); } groupsList.appendChild(lbl); }); } } // Rendu de la liste des utilisateurs const usersList = document.getElementById('perm-users-list'); if (usersList) { usersList.textContent = ''; const qU = state.permissions.userSearch.trim().toLowerCase(); const filteredUsers = state.permissions.availableUsers.filter((u) => { if (qU === '') { return true; } return (u.uid + ' ' + u.displayName + ' ' + (u.email || '')).toLowerCase().includes(qU); }); if (filteredUsers.length === 0) { const empty = document.createElement('div'); empty.className = 'ul-perm-empty'; empty.textContent = 'Aucun utilisateur Nextcloud trouvé.'; usersList.appendChild(empty); } else { filteredUsers.forEach((user) => { const isChecked = Boolean(user.isAdmin) || state.permissions.allowedUsers.has(user.uid); const lbl = document.createElement('label'); lbl.className = 'ul-perm-item' + (isChecked ? ' selected' : ''); const chk = document.createElement('input'); chk.type = 'checkbox'; chk.dataset.permUser = user.uid; chk.checked = isChecked; if (user.isAdmin) { chk.disabled = true; } const info = document.createElement('div'); info.className = 'ul-perm-item-info'; const title = document.createElement('div'); title.className = 'ul-perm-item-title'; title.textContent = user.displayName + ' (' + user.uid + ')'; const sub = document.createElement('div'); sub.className = 'ul-perm-item-sub'; sub.textContent = user.email ? ('📧 ' + user.email) : 'Aucun e-mail renseigné'; info.appendChild(title); info.appendChild(sub); lbl.appendChild(chk); lbl.appendChild(info); if (user.isAdmin) { const badge = document.createElement('span'); badge.className = 'ul-badge ul-badge-green'; badge.textContent = 'Admin Nextcloud'; lbl.appendChild(badge); } usersList.appendChild(lbl); }); } } setText('perm-groups-count', String(state.permissions.allowedGroups.size)); setText('perm-users-count', String(state.permissions.allowedUsers.size)); } async function handleSavePermissions() { try { const res = await apiFetch('/api/permissions', { method: 'PUT', body: JSON.stringify({ access_mode: state.permissions.accessMode, allowed_groups: Array.from(state.permissions.allowedGroups), allowed_users: Array.from(state.permissions.allowedUsers), }), }); if (res) { state.permissions.accessMode = res.access_mode === 'all_users' ? 'all_users' : 'restricted'; state.permissions.allowedGroups = new Set(Array.isArray(res.allowed_groups) ? res.allowed_groups : []); state.permissions.allowedUsers = new Set(Array.isArray(res.allowed_users) ? res.allowed_users : []); renderPermissions(); } showToast('Permissions d\'accès enregistrées avec succès.'); } catch (err) { showToast('Erreur d\'enregistrement des permissions : ' + err.message, 'error'); } } /** * Ouvre ou ferme le volet latéral gauche (#app-navigation). */ function toggleSidebar(forceCollapsed) { const wrapper = document.getElementById('ul-tresorerie-app'); if (!wrapper) { return; } const isCollapsed = typeof forceCollapsed === 'boolean' ? forceCollapsed : !wrapper.classList.contains('ul-sidebar-collapsed'); wrapper.classList.toggle('ul-sidebar-collapsed', isCollapsed); try { window.localStorage.setItem('ul_tresorerie_sidebar_collapsed', isCollapsed ? '1' : '0'); } catch (_err) { // Ignorer si localStorage est indisponible } } /** * Bascule entre les vues (Grille, Tableau de bord, Publipostage, Permissions). */ function switchView(viewName) { const allowedViews = ['grid', 'dashboard', 'mailing', 'permissions']; if (!allowedViews.includes(viewName)) { viewName = 'grid'; } if (viewName === 'permissions' && !state.isAdmin) { viewName = 'grid'; } state.activeView = viewName; allowedViews.forEach((v) => { const sec = document.getElementById('view-' + v); const nav = document.getElementById('nav-view-' + v); if (sec) { sec.classList.toggle('hidden', v !== viewName); } if (nav) { nav.classList.toggle('active', v === viewName); } }); if (viewName === 'mailing') { syncTemplateEditorFromSlug(state.activeTemplateSlug, state.selectedRecipientIds.size === 0); } else if (viewName === 'dashboard') { renderDashboard(); } else if (viewName === 'permissions') { if (!state.permissionsLoaded) { loadPermissions(); } else { renderPermissions(); } } else { renderGrid(); } } function on(id, eventName, handler) { const el = document.getElementById(id); if (el) { el.addEventListener(eventName, handler); } } /** * Attache tous les écouteurs d'événements DOM (100 % conforme CSP Nextcloud 34). */ function bindEvents() { // Boutons d'ouverture / fermeture du volet latéral gauche document.querySelectorAll('[data-action="toggle-sidebar"]').forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); toggleSidebar(); }); }); // Navigation entre vues (Grille, Tableau de bord, Publipostage, Permissions) document.querySelectorAll('.ul-nav-item[data-view]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); const targetView = link.getAttribute('data-view') || 'grid'; switchView(targetView); }); }); // Support du changement de hash dans l'URL (#dashboard, #permissions, #mailing, #grid) window.addEventListener('hashchange', () => { const hash = (window.location.hash || '').replace(/^#/, ''); if (['grid', 'dashboard', 'mailing', 'permissions'].includes(hash)) { switchView(hash); } }); // Filtres d'alertes dans la barre latérale document.querySelectorAll('.ul-filter-item[data-filter]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); document.querySelectorAll('.ul-filter-item').forEach((el) => el.classList.remove('active')); link.classList.add('active'); state.activeFilter = link.dataset.filter || 'all'; if (state.activeView !== 'grid') { switchView('grid'); } else { renderGrid(); } }); }); on('active-filter-indicator', 'click', () => { state.activeFilter = 'all'; document.querySelectorAll('.ul-filter-item').forEach((el) => { el.classList.toggle('active', el.dataset.filter === 'all'); }); renderGrid(); }); // Sélecteur d'année (< 2026 >) dans la grille et dans le tableau de bord const prevYearFn = () => loadState(state.year - 1); const nextYearFn = () => loadState(state.year + 1); on('btn-prev-year', 'click', prevYearFn); on('btn-next-year', 'click', nextYearFn); on('btn-dash-prev-year', 'click', prevYearFn); on('btn-dash-next-year', 'click', nextYearFn); on('dash-select-compare-year', 'change', (e) => { state.compareYear = Number(e.target.value) || (state.year - 1); renderDashboard(); }); on('dash-chart-multiyear', 'click', (e) => { const grp = e.target.closest('[data-year]'); if (grp && grp.dataset.year) { const targetYear = Number(grp.dataset.year); if (targetYear && targetYear !== state.year) { loadState(targetYear); } } }); // Barre de recherche instantanée on('input-search', 'input', (e) => { state.searchQuery = e.target.value || ''; renderGrid(); }); // Case à cocher « Afficher les syndiqués archivés » on('chk-show-archived', 'change', (e) => { state.showArchived = Boolean(e.target.checked); renderGrid(); }); // Boutons « + Nouveau syndiqué » et « Importer fichier .asp » const openNewMemberFn = () => openMemberModal(null); on('btn-new-member', 'click', openNewMemberFn); on('btn-nav-new-member', 'click', openNewMemberFn); const triggerFileImportFn = () => { const fileInput = document.getElementById('input-import-file'); if (fileInput) { fileInput.click(); } }; on('btn-import-asp', 'click', triggerFileImportFn); on('btn-nav-import-asp', 'click', triggerFileImportFn); on('input-import-file', 'change', handleImportFileChange); // Délégation d'événements sur la grille des cotisations (clic cellule ou édition fiche) on('dues-tbody', 'click', (e) => { const editBtn = e.target.closest('[data-action="edit-member"]'); if (editBtn) { e.stopPropagation(); openMemberModal(Number(editBtn.dataset.memberId)); return; } const dueCell = e.target.closest('[data-action="open-due"]'); if (dueCell) { openDueModal(Number(dueCell.dataset.memberId), Number(dueCell.dataset.month)); } }); // Modale de saisie de cotisation on('due-form', 'submit', handleDueFormSubmit); on('btn-delete-due', 'click', handleDeleteDue); on('btn-cancel-due', 'click', closeDueModal); on('btn-close-due-modal', 'click', closeDueModal); // Modale de fiche syndiqué on('member-form', 'submit', handleMemberFormSubmit); on('btn-delete-member', 'click', handleDeleteMember); on('btn-cancel-member', 'click', closeMemberModal); on('btn-close-member-modal', 'click', closeMemberModal); // Fermeture des modales au clic sur l'arrière-plan ou touche Escape on('due-modal', 'click', (e) => { if (e.target.id === 'due-modal') { closeDueModal(); } }); on('member-modal', 'click', (e) => { if (e.target.id === 'member-modal') { closeMemberModal(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeDueModal(); closeMemberModal(); } }); // Événements de l'outil de Publipostage on('select-template', 'change', (e) => { syncTemplateEditorFromSlug(e.target.value, true); }); on('chk-mailing-no-email-only', 'change', (e) => { state.mailingNoEmailOnly = Boolean(e.target.checked); applyAutoRecipientSelection(state.activeTemplateSlug); renderMailingRecipients(); renderMailingPreview(); }); on('btn-mailing-check-all', 'click', () => { getEligibleMailingMembers().forEach((m) => state.selectedRecipientIds.add(Number(m.id))); renderMailingRecipients(); }); on('btn-mailing-uncheck-all', 'click', () => { state.selectedRecipientIds.clear(); renderMailingRecipients(); }); on('mailing-recipients-list', 'change', (e) => { const chk = e.target.closest('[data-action="toggle-recipient"]'); if (!chk) { return; } const id = Number(chk.dataset.memberId); if (chk.checked) { state.selectedRecipientIds.add(id); state.previewMemberId = id; } else { state.selectedRecipientIds.delete(id); } renderMailingRecipients(); renderMailingPreview(); }); on('select-preview-member', 'change', (e) => { state.previewMemberId = Number(e.target.value); renderMailingPreview(); }); const tplSubject = document.getElementById('tpl-subject'); const tplBody = document.getElementById('tpl-body'); if (tplSubject) { tplSubject.addEventListener('focus', () => { state.lastFocusedTemplateField = tplSubject; }); tplSubject.addEventListener('input', renderMailingPreview); } if (tplBody) { tplBody.addEventListener('focus', () => { state.lastFocusedTemplateField = tplBody; }); tplBody.addEventListener('input', renderMailingPreview); } on('tpl-variables-bar', 'click', (e) => { const pill = e.target.closest('.ul-var-pill'); if (pill && pill.dataset.var) { insertVariableAtCursor(pill.dataset.var); } }); on('btn-save-template', 'click', handleSaveTemplate); on('btn-reset-templates', 'click', handleResetTemplates); on('btn-print-mailing', 'click', handlePrintMailing); on('btn-email-mailing', 'click', handleEmailMailing); // Événements de la vue Permissions (si administrateur) on('btn-save-permissions', 'click', handleSavePermissions); document.querySelectorAll('input[name="perm-access-mode"]').forEach((radio) => { radio.addEventListener('change', (e) => { if (e.target.checked) { state.permissions.accessMode = e.target.value === 'all_users' ? 'all_users' : 'restricted'; renderPermissions(); } }); }); on('input-perm-search-groups', 'input', (e) => { state.permissions.groupSearch = e.target.value || ''; renderPermissions(); }); on('input-perm-search-users', 'input', (e) => { state.permissions.userSearch = e.target.value || ''; renderPermissions(); }); on('perm-groups-list', 'change', (e) => { const chk = e.target.closest('input[data-perm-group]'); if (!chk) { return; } const gid = chk.dataset.permGroup; if (chk.checked) { state.permissions.allowedGroups.add(gid); } else { state.permissions.allowedGroups.delete(gid); } renderPermissions(); }); on('perm-users-list', 'change', (e) => { const chk = e.target.closest('input[data-perm-user]'); if (!chk) { return; } const uid = chk.dataset.permUser; if (chk.checked) { state.permissions.allowedUsers.add(uid); } else { state.permissions.allowedUsers.delete(uid); } renderPermissions(); }); on('btn-perm-groups-all', 'click', () => { state.permissions.availableGroups.forEach((g) => { if (g.id !== 'admin') { state.permissions.allowedGroups.add(g.id); } }); renderPermissions(); }); on('btn-perm-groups-none', 'click', () => { state.permissions.allowedGroups.clear(); renderPermissions(); }); on('btn-perm-users-all', 'click', () => { state.permissions.availableUsers.forEach((u) => { if (!u.isAdmin) { state.permissions.allowedUsers.add(u.uid); } }); renderPermissions(); }); on('btn-perm-users-none', 'click', () => { state.permissions.allowedUsers.clear(); renderPermissions(); }); } document.addEventListener('DOMContentLoaded', () => { const wrapper = document.getElementById('ul-tresorerie-app'); if (!wrapper) { return; } const initialYear = Number(wrapper.dataset.initialYear) || new Date().getFullYear(); state.year = initialYear; state.compareYear = initialYear - 1; state.canAccess = wrapper.dataset.canAccess !== '0'; state.isAdmin = wrapper.dataset.isAdmin === '1'; state.logoUrl = wrapper.dataset.logoUrl || ''; state.bannerUrl = wrapper.dataset.bannerUrl || ''; if (!state.canAccess) { return; } try { if (window.localStorage.getItem('ul_tresorerie_sidebar_collapsed') === '1') { wrapper.classList.add('ul-sidebar-collapsed'); } } catch (_err) { // Ignorer } bindEvents(); loadState(initialYear); const initialHash = (window.location.hash || '').replace(/^#/, ''); if (['dashboard', 'mailing', 'permissions'].includes(initialHash)) { switchView(initialHash); } }); })();