Contrôle complet + corrections de bugs + améliorations UI

Vérification de bout en bout via la vraie config nginx (21 checks
Playwright, tous passants) : parcours admin complet, saisie cadre,
double validation, verrouillages, exports, erreurs de connexion.

Bugs corrigés découverts par ce contrôle :
- La création d'utilisateur via l'interface était cassée depuis le
  début : l'id "user-role" existait en double (badge de l'en-tête +
  select de la modale), getElementById renvoyait le span du header et
  le rôle n'était jamais envoyé (400 systématique). Le badge devient
  user-role-badge.
- Les toasts de notification intercep­taient les clics sur les boutons
  situés dessous pendant 4 s (pointer-events: none ajouté).

Améliorations UI :
- « Absent » renommé « Non-présent » partout (légende, popover,
  exports PDF et Excel — code NP au lieu de A).
- Récapitulatif mensuel : tuiles de compteurs par statut au-dessus du
  calendrier, mises à jour à chaque saisie.
- Bouton « Remplir les jours ouvrés en Présent » : renseigne en un clic
  toutes les demi-journées vides du lundi au vendredi (nouvel endpoint
  PUT /api/attendance/bulk, transactionnel, mêmes contrôles de
  verrouillage), sans toucher aux saisies existantes.
- Jour actuel mis en évidence dans le calendrier.
- Favicon.
This commit is contained in:
Claude committed 2026-07-06 15:04:03 +00:00
1 parent 441a1758a5
commit 7ea223a442
7 files changed
+146 -11

No files matched your search

+55
View File
@@ -117,6 +117,61 @@ router.put('/', async (req, res) => {
res.json({ ok: true });
});
// Bulk upsert (e.g. « fill all empty weekdays with présent »). All entries
// must pass the same lock checks as single writes; months are checked once
// per distinct month present in the payload.
router.put('/bulk', async (req, res) => {
const { entries, user_id: requestedUserId } = req.body || {};
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 200) {
return res.status(400).json({ error: 'Paramètres invalides' });
}
for (const e of entries) {
if (!e || !e.date || !PERIODS.includes(e.period) || !STATUSES.includes(e.status)) {
return res.status(400).json({ error: 'Paramètres invalides' });
}
}
if (requestedUserId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Accès refusé' });
}
const target = await getTargetUser(req, requestedUserId);
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
const months = new Set(entries.map((e) => e.date.slice(0, 7)));
for (const ym of months) {
const [year, month] = ym.split('-').map(Number);
const lock = await getMonthLock(target.id, year, month);
const companyValidation = await getCompanyValidation(target.company_id, year, month);
if (companyValidation.admin_validated) {
return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
}
if (req.user.role !== 'admin' && lock.cadre_validated) {
return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
}
}
const client = await db.pool.connect();
try {
await client.query('BEGIN');
for (const e of entries) {
await client.query(
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (user_id, entry_date, period)
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
[target.id, e.date, e.period, e.status]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
res.json({ ok: true, count: entries.length });
});
router.delete('/', async (req, res) => {
const { date, period, user_id: requestedUserId } = req.body || {};
if (!date || !PERIODS.includes(period)) {
+1 -1
View File
@@ -75,7 +75,7 @@ async function buildCompanyWorkbook({ companyName, year, month, cadres }) {
}
const legendRowIdx = sheet.rowCount + 2;
sheet.getCell(legendRowIdx, 1).value = 'Légende : P = Présent · A = Absent · CP = Congé · RTT = RTT';
sheet.getCell(legendRowIdx, 1).value = 'Légende : P = Présent · NP = Non-présent · CP = Congé · RTT = RTT';
sheet.getCell(legendRowIdx, 1).font = { italic: true, size: 9 };
return workbook;
+1 -1
View File
@@ -81,7 +81,7 @@ function buildCompanyPdf({ companyName, year, month, cadres }) {
y = 40;
}
doc.fontSize(10).fillColor('#111').text(
`Total demi-journées — Présent: ${counts.present} · Absent: ${counts.absent} · Congé: ${counts.conge} · RTT: ${counts.rtt}`,
`Total demi-journées — Présent: ${counts.present} · Non-présent: ${counts.absent} · Congé: ${counts.conge} · RTT: ${counts.rtt}`,
startX,
y
);
+2 -2
View File
@@ -1,13 +1,13 @@
const STATUS_LABELS = {
present: 'Présent',
absent: 'Absent',
absent: 'Non-présent',
conge: 'Congé',
rtt: 'RTT',
};
const STATUS_CODES = {
present: 'P',
absent: 'A',
absent: 'NP',
conge: 'CP',
rtt: 'RTT',
};
+31 -1
View File
@@ -250,7 +250,35 @@ h2 { font-size: 17px; margin: 0 0 4px; }
.half-day.st-conge { background: var(--color-conge-bg); color: var(--color-conge); border-style: solid; border-color: transparent; }
.half-day.st-rtt { background: var(--color-rtt-bg); color: var(--color-rtt); border-style: solid; border-color: transparent; }
.calendar-actions { margin-top: 22px; display: flex; justify-content: flex-end; }
.calendar-actions { margin-top: 22px; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
/* Highlight of the current day */
.day-cell.today { border-color: var(--color-primary); box-shadow: inset 0 0 0 1px var(--color-primary); }
.day-cell.today .day-number { color: var(--color-primary); }
/* ---------- Monthly summary tiles ---------- */
.month-summary {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.month-summary:empty { display: none; }
.summary-tile {
display: flex;
align-items: baseline;
gap: 7px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 8px 14px;
}
.summary-count { font-size: 18px; font-weight: 700; }
.summary-label { font-size: 12px; color: var(--color-muted); font-weight: 600; }
.summary-present .summary-count { color: var(--color-present); }
.summary-absent .summary-count { color: var(--color-absent); }
.summary-conge .summary-count { color: var(--color-conge); }
.summary-rtt .summary-count { color: var(--color-rtt); }
/* ---------- Tables ---------- */
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
@@ -318,6 +346,8 @@ h2 { font-size: 17px; margin: 0 0 4px; }
.toast-container {
position: fixed; top: 18px; right: 18px; z-index: 200;
display: flex; flex-direction: column; gap: 10px;
/* Notifications must never block clicks on the UI underneath them. */
pointer-events: none;
}
.toast {
background: var(--color-text); color: #fff; padding: 12px 18px; border-radius: 8px;
+7 -3
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Presencia — Gestion des présences</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%233949ab'/%3E%3Ctext x='50' y='72' font-size='64' text-anchor='middle' fill='white' font-family='Arial' font-weight='bold'%3EP%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
@@ -51,7 +52,7 @@
<div class="topbar-user">
<div class="user-chip">
<span id="user-name" class="user-name"></span>
<span id="user-role" class="user-role-badge"></span>
<span id="user-role-badge" class="user-role-badge"></span>
</div>
<button id="logout-btn" class="btn btn-ghost btn-sm">Déconnexion</button>
</div>
@@ -75,18 +76,21 @@
<div class="legend">
<span class="legend-item"><i class="dot dot-present"></i>Présent</span>
<span class="legend-item"><i class="dot dot-absent"></i>Absent</span>
<span class="legend-item"><i class="dot dot-absent"></i>Non-présent</span>
<span class="legend-item"><i class="dot dot-conge"></i>Congé</span>
<span class="legend-item"><i class="dot dot-rtt"></i>RTT</span>
<span class="legend-item"><i class="dot dot-empty"></i>Non renseigné</span>
</div>
<div id="month-summary" class="month-summary"></div>
<div class="calendar-weekdays">
<span>Lun</span><span>Mar</span><span>Mer</span><span>Jeu</span><span>Ven</span><span>Sam</span><span>Dim</span>
</div>
<div id="calendar-grid" class="calendar-grid"></div>
<div id="calendar-actions" class="calendar-actions">
<button id="fill-workdays-btn" class="btn btn-outline">Remplir les jours ouvrés en « Présent »</button>
<button id="validate-month-btn" class="btn btn-primary">Valider mon mois</button>
</div>
</section>
@@ -190,7 +194,7 @@
<div id="status-popover" class="popover" hidden>
<div class="popover-title" id="popover-title"></div>
<button class="popover-option" data-status="present"><i class="dot dot-present"></i>Présent</button>
<button class="popover-option" data-status="absent"><i class="dot dot-absent"></i>Absent</button>
<button class="popover-option" data-status="absent"><i class="dot dot-absent"></i>Non-présent</button>
<button class="popover-option" data-status="conge"><i class="dot dot-conge"></i>Congé</button>
<button class="popover-option" data-status="rtt"><i class="dot dot-rtt"></i>RTT</button>
<button class="popover-option popover-clear" data-status="">Effacer</button>
+49 -3
View File
@@ -6,7 +6,7 @@
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
];
const STATUS_LABELS = { present: 'Présent', absent: 'Absent', conge: 'Congé', rtt: 'RTT' };
const STATUS_LABELS = { present: 'Présent', absent: 'Non-présent', conge: 'Congé', rtt: 'RTT' };
const state = {
user: null,
@@ -69,7 +69,7 @@
document.getElementById('view-login').hidden = true;
document.getElementById('app').hidden = false;
document.getElementById('user-name').textContent = user.fullName;
document.getElementById('user-role').textContent = user.role === 'admin' ? 'Administrateur' : (user.companyName || 'Cadre');
document.getElementById('user-role-badge').textContent = user.role === 'admin' ? 'Administrateur' : (user.companyName || 'Cadre');
const now = new Date();
state.cal.year = now.getFullYear();
@@ -197,10 +197,26 @@
actionsBox.hidden = false;
validateBtn.disabled = data.cadreValidated || data.companyValidated;
validateBtn.textContent = data.cadreValidated ? 'Mois déjà validé' : 'Valider mon mois';
document.getElementById('fill-workdays-btn').hidden = !data.editable;
} else {
actionsBox.hidden = true;
}
// monthly totals (half-days per status)
const counts = { present: 0, absent: 0, conge: 0, rtt: 0 };
data.entries.forEach((e) => { if (counts[e.status] !== undefined) counts[e.status]++; });
document.getElementById('month-summary').innerHTML = ['present', 'absent', 'conge', 'rtt']
.map((s) => `
<div class="summary-tile summary-${s}">
<span class="summary-count">${counts[s]}</span>
<span class="summary-label">${STATUS_LABELS[s]}</span>
</div>`)
.join('') + `
<div class="summary-tile summary-note">
<span class="summary-count">${data.entries.length}</span>
<span class="summary-label">demi-journées saisies</span>
</div>`;
const firstOfMonth = new Date(year, month - 1, 1);
const daysInMonth = new Date(year, month, 0).getDate();
let firstWeekday = firstOfMonth.getDay(); // 0=Sun
@@ -215,13 +231,15 @@
}
const editable = data.editable;
const now = new Date();
const todayDay = now.getFullYear() === year && now.getMonth() + 1 === month ? now.getDate() : null;
for (let d = 1; d <= daysInMonth; d++) {
const dateObj = new Date(year, month - 1, d);
const iso = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
const cell = document.createElement('div');
cell.className = 'day-cell' + (isWeekend ? ' weekend' : '');
cell.className = 'day-cell' + (isWeekend ? ' weekend' : '') + (d === todayDay ? ' today' : '');
const num = document.createElement('div');
num.className = 'day-number';
@@ -318,6 +336,34 @@
}
});
document.getElementById('fill-workdays-btn').addEventListener('click', async () => {
const { year, month, data } = state.cal;
if (!data) return;
const filled = new Set(data.entries.map((e) => `${e.date}:${e.period}`));
const entries = [];
const daysInMonth = new Date(year, month, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const dow = new Date(year, month - 1, d).getDay();
if (dow === 0 || dow === 6) continue; // week-ends exclus
const iso = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
for (const period of ['AM', 'PM']) {
if (!filled.has(`${iso}:${period}`)) entries.push({ date: iso, period, status: 'present' });
}
}
if (entries.length === 0) {
toast('Tous les jours ouvrés sont déjà renseignés', 'info');
return;
}
if (!confirm(`Renseigner ${entries.length} demi-journées vides en « Présent » ? Les saisies existantes ne seront pas modifiées.`)) return;
try {
await api('/attendance/bulk', { method: 'PUT', body: { entries } });
toast(`${entries.length} demi-journées renseignées`, 'success');
await loadCalendar();
} catch (err) {
toast(err.message, 'error');
}
});
// ================= COMPANIES (admin) =================
async function loadCompaniesForSelects() {
try {