mirror of
https://github.com/R0m1k3/Presencia.git
synced 2026-10-15 03:09:43 +02:00
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 interceptaient 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:
7 files changed
+146
-11
No files matched your search
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user