2618 lines
101 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
})();
|