cgt-nc-treso/ul_tresorerie/js/main.js

2618 lines
101 KiB
JavaScript

'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);
}
});
})();