Ajout de l'application Presencia (gestion des présences multi-sociétés)

Application complète : API Node/Express + PostgreSQL, frontend HTML/JS
mono-page, et déploiement Docker sur ports non standards (8781/4790/6543).

- Cadres : calendrier mensuel, saisie de présence par demi-journée
  (présent/absent/congé/RTT), validation de fin de mois qui verrouille
  leurs propres saisies.
- Admin : gestion des sociétés et utilisateurs (rattachement société <->
  cadre), consultation de tous les plannings, validation d'une société
  entière une fois tous ses cadres validés, réouverture en cas de
  correction, export PDF et Excel par société/mois.
This commit is contained in:
Claude committed 2026-07-04 20:41:27 +00:00
commit a73ab28fae
27 files changed
+5492

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY public /usr/share/nginx/html
EXPOSE 80
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:4790/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+338
View File
@@ -0,0 +1,338 @@
:root {
--color-primary: #3949ab;
--color-primary-dark: #2c3593;
--color-primary-light: #e8eaf6;
--color-bg: #f4f5f9;
--color-surface: #ffffff;
--color-border: #e2e4ec;
--color-text: #1c1f2a;
--color-muted: #6b7080;
--color-present: #2e7d32;
--color-present-bg: #e6f4ea;
--color-absent: #c62828;
--color-absent-bg: #fbe9e9;
--color-conge: #ef6c00;
--color-conge-bg: #fdf0e2;
--color-rtt: #1565c0;
--color-rtt-bg: #e6eefb;
--color-empty: #c7c9d3;
--radius: 10px;
--shadow: 0 1px 3px rgba(20, 22, 40, 0.08), 0 1px 2px rgba(20, 22, 40, 0.06);
--shadow-lg: 0 12px 32px rgba(20, 22, 40, 0.16);
}
* { box-sizing: border-box; }
/* Any element toggled via the `hidden` attribute must stay hidden even if a
class on it (e.g. display:flex) would otherwise override the UA default. */
[hidden] { display: none !important; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--color-bg);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
button { font-family: inherit; cursor: pointer; }
input, select { font-family: inherit; }
.brand { display: flex; align-items: center; gap: 8px; }
.brand-mark {
color: var(--color-primary);
font-size: 22px;
}
.brand-name {
font-weight: 700;
font-size: 20px;
letter-spacing: -0.02em;
color: var(--color-text);
}
/* ---------- Login ---------- */
.login-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #3949ab 0%, #1a237e 100%);
padding: 20px;
}
.login-card {
background: var(--color-surface);
border-radius: 16px;
box-shadow: var(--shadow-lg);
padding: 40px 36px;
width: 100%;
max-width: 380px;
}
.login-card .brand { justify-content: center; margin-bottom: 6px; }
.login-subtitle {
text-align: center;
color: var(--color-muted);
margin: 0 0 28px;
font-size: 14px;
}
.field { display: block; margin-bottom: 16px; }
.field span { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; color: var(--color-text); }
.field input {
width: 100%;
padding: 11px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 14px;
background: #fbfbfd;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
background: #fff;
}
.login-hint { text-align: center; color: var(--color-muted); font-size: 12px; margin: 20px 0 0; }
.form-error {
background: var(--color-absent-bg);
color: var(--color-absent);
font-size: 13px;
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 14px;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: 8px;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
transition: background .15s, box-shadow .15s, color .15s, opacity .15s;
}
.btn-primary { background: var(--color-primary); color: #fff; }
.btn-primary:hover { background: var(--color-primary-dark); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-outline { background: #fff; color: var(--color-primary); border: 1px solid var(--color-primary); }
.btn-outline:hover { background: var(--color-primary-light); }
.btn-outline:disabled { opacity: .4; cursor: not-allowed; }
.btn-ghost { background: transparent; color: var(--color-muted); }
.btn-ghost:hover { background: #eceef4; color: var(--color-text); }
.btn-danger { background: #fff; color: var(--color-absent); border: 1px solid var(--color-absent); }
.btn-danger:hover { background: var(--color-absent-bg); }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.btn-block { width: 100%; }
.icon-btn {
border: none; background: #eef0f6; color: var(--color-text);
width: 32px; height: 32px; border-radius: 8px; font-size: 18px; line-height: 1;
}
.icon-btn:hover { background: var(--color-primary-light); color: var(--color-primary); }
/* ---------- App shell ---------- */
.app { min-height: 100vh; display: flex; flex-direction: column; }
.topbar {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.topbar-nav { display: flex; gap: 4px; flex-wrap: wrap; }
.nav-btn {
border: none; background: transparent; padding: 8px 14px; border-radius: 8px;
font-size: 14px; font-weight: 600; color: var(--color-muted);
}
.nav-btn:hover { background: #eef0f6; color: var(--color-text); }
.nav-btn.active { background: var(--color-primary-light); color: var(--color-primary); }
.topbar-user { display: flex; align-items: center; gap: 12px; }
.user-chip { text-align: right; }
.user-name { display: block; font-weight: 600; font-size: 14px; }
.user-role-badge {
display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-primary); font-weight: 700;
}
.main { flex: 1; padding: 28px 32px 60px; max-width: 1180px; width: 100%; margin: 0 auto; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.view-header h1 { font-size: 22px; margin: 0; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 17px; margin: 0 0 4px; }
.muted { color: var(--color-muted); font-size: 13px; margin: 0; }
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
margin-bottom: 20px;
}
.selector-card { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.selector-card label { font-size: 13px; font-weight: 600; color: var(--color-text); display: flex; flex-direction: column; gap: 6px; }
.selector-card select {
padding: 9px 10px; border-radius: 8px; border: 1px solid var(--color-border); font-size: 14px; min-width: 180px;
}
/* ---------- Calendar ---------- */
.calendar-header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
.calendar-title-group h1 { margin-bottom: 8px; }
.month-nav { display: flex; align-items: center; gap: 12px; }
.month-label { font-size: 16px; font-weight: 700; min-width: 160px; text-align: center; text-transform: capitalize; }
.validation-banner {
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 600;
background: #eef0f6; color: var(--color-muted); max-width: 360px;
}
.validation-banner.locked { background: var(--color-rtt-bg); color: var(--color-rtt); }
.validation-banner.validated { background: var(--color-present-bg); color: var(--color-present); }
.legend { display: flex; gap: 18px; flex-wrap: wrap; margin-bottom: 16px; font-size: 13px; color: var(--color-muted); }
.legend-item { display: flex; align-items: center; gap: 6px; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot-present { background: var(--color-present); }
.dot-absent { background: var(--color-absent); }
.dot-conge { background: var(--color-conge); }
.dot-rtt { background: var(--color-rtt); }
.dot-empty { background: var(--color-empty); }
.calendar-weekdays {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px;
font-size: 12px; font-weight: 700; color: var(--color-muted); text-transform: uppercase;
padding: 0 2px; margin-bottom: 6px;
}
.calendar-grid {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px;
}
.day-cell {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
min-height: 84px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.day-cell.empty { background: transparent; border: none; }
.day-cell.weekend { background: #fafafc; }
.day-number { font-size: 13px; font-weight: 700; color: var(--color-text); }
.half-day-row { display: flex; gap: 4px; flex: 1; }
.half-day {
flex: 1;
border-radius: 6px;
border: 1px dashed var(--color-border);
background: #fafbfd;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: var(--color-muted);
display: flex;
align-items: center;
justify-content: center;
min-height: 34px;
transition: transform .1s, box-shadow .1s;
}
.half-day:not(.readonly):hover { transform: translateY(-1px); box-shadow: var(--shadow); }
.half-day.readonly { cursor: default; }
.half-day.st-present { background: var(--color-present-bg); color: var(--color-present); border-style: solid; border-color: transparent; }
.half-day.st-absent { background: var(--color-absent-bg); color: var(--color-absent); border-style: solid; border-color: transparent; }
.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; }
/* ---------- Tables ---------- */
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.data-table th {
text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .03em;
color: var(--color-muted); padding: 10px 12px; border-bottom: 2px solid var(--color-border);
}
.data-table td { padding: 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
.data-table tr:last-child td { border-bottom: none; }
.filter-row { margin-bottom: 14px; }
.filter-row select { padding: 7px 10px; border-radius: 8px; border: 1px solid var(--color-border); }
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.badge-admin { background: var(--color-primary-light); color: var(--color-primary); }
.badge-cadre { background: #eef0f6; color: var(--color-muted); }
.badge-active { background: var(--color-present-bg); color: var(--color-present); }
.badge-inactive { background: var(--color-absent-bg); color: var(--color-absent); }
.badge-validated { background: var(--color-present-bg); color: var(--color-present); }
.badge-pending { background: #fdf0e2; color: var(--color-conge); }
.row-actions { display: flex; gap: 8px; }
/* ---------- Validation summary ---------- */
.validation-summary-header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
.validation-summary-actions { display: flex; gap: 10px; }
.export-row { display: flex; align-items: center; gap: 12px; margin-top: 18px; font-size: 14px; font-weight: 600; color: var(--color-muted); }
/* ---------- Popover ---------- */
.popover {
position: absolute;
background: var(--color-surface);
border-radius: 10px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--color-border);
padding: 8px;
z-index: 60;
min-width: 160px;
}
.popover-title { font-size: 12px; font-weight: 700; color: var(--color-muted); padding: 4px 8px 8px; }
.popover-option {
display: flex; align-items: center; gap: 8px; width: 100%;
border: none; background: transparent; padding: 9px 8px; border-radius: 6px;
font-size: 14px; text-align: left; font-weight: 600; color: var(--color-text);
}
.popover-option:hover { background: #eef0f6; }
.popover-clear { color: var(--color-muted); }
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(20, 22, 40, .45);
display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px;
}
.modal {
background: var(--color-surface); border-radius: 14px; box-shadow: var(--shadow-lg);
padding: 26px; width: 100%; max-width: 420px;
}
.modal h2 { margin-bottom: 18px; }
.modal .field { margin-bottom: 14px; }
.modal .field input, .modal .field select {
width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--color-border); font-size: 14px;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
/* ---------- Toasts ---------- */
.toast-container {
position: fixed; top: 18px; right: 18px; z-index: 200;
display: flex; flex-direction: column; gap: 10px;
}
.toast {
background: var(--color-text); color: #fff; padding: 12px 18px; border-radius: 8px;
font-size: 14px; box-shadow: var(--shadow-lg); max-width: 320px; opacity: 0; transform: translateY(-8px);
animation: toast-in .2s forwards;
}
.toast.toast-error { background: var(--color-absent); }
.toast.toast-success { background: var(--color-present); }
@keyframes toast-in { to { opacity: 1; transform: translateY(0); } }
.view[hidden] { display: none; }
@media (max-width: 720px) {
.main { padding: 20px 14px 50px; }
.day-cell { min-height: 70px; }
.half-day { font-size: 9px; min-height: 28px; }
.selector-card { flex-direction: column; align-items: stretch; }
}
+200
View File
@@ -0,0 +1,200 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Presencia — Gestion des présences</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div id="toast-container" class="toast-container"></div>
<!-- LOGIN VIEW -->
<section id="view-login" class="login-screen">
<div class="login-card">
<div class="brand">
<span class="brand-mark">◆</span>
<span class="brand-name">Presencia</span>
</div>
<p class="login-subtitle">Gestion des présences par société</p>
<form id="login-form" autocomplete="on">
<label class="field">
<span>Adresse e-mail</span>
<input type="email" id="login-email" required autocomplete="username" placeholder="vous@societe.com" />
</label>
<label class="field">
<span>Mot de passe</span>
<input type="password" id="login-password" required autocomplete="current-password" placeholder="••••••••" />
</label>
<div id="login-error" class="form-error" hidden></div>
<button type="submit" class="btn btn-primary btn-block">Se connecter</button>
</form>
<p class="login-hint">Accès réservé — comptes créés par l'administrateur.</p>
</div>
</section>
<!-- MAIN APP -->
<div id="app" class="app" hidden>
<header class="topbar">
<div class="topbar-brand">
<span class="brand-mark">◆</span>
<span class="brand-name">Presencia</span>
</div>
<nav id="admin-nav" class="topbar-nav" hidden>
<button class="nav-btn" data-view="calendar" data-admin-mode="own">Mon planning</button>
<button class="nav-btn" data-view="companies">Sociétés</button>
<button class="nav-btn" data-view="users">Utilisateurs</button>
<button class="nav-btn" data-view="plannings">Plannings</button>
<button class="nav-btn" data-view="validation">Validation &amp; export</button>
</nav>
<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>
</div>
<button id="logout-btn" class="btn btn-ghost btn-sm">Déconnexion</button>
</div>
</header>
<main class="main">
<!-- CALENDAR VIEW (cadre self, or admin viewing a cadre) -->
<section id="view-calendar" class="view">
<div class="calendar-header">
<div class="calendar-title-group">
<h1 id="calendar-owner-name">Mon planning</h1>
<div class="month-nav">
<button id="prev-month" class="icon-btn" aria-label="Mois précédent">‹</button>
<span id="month-label" class="month-label"></span>
<button id="next-month" class="icon-btn" aria-label="Mois suivant">›</button>
</div>
</div>
<div id="validation-banner" class="validation-banner"></div>
</div>
<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-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 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="validate-month-btn" class="btn btn-primary">Valider mon mois</button>
</div>
</section>
<!-- COMPANIES VIEW (admin) -->
<section id="view-companies" class="view" hidden>
<div class="view-header">
<h1>Sociétés</h1>
<button id="add-company-btn" class="btn btn-primary">+ Nouvelle société</button>
</div>
<div class="card">
<table class="data-table">
<thead><tr><th>Nom</th><th>Cadres actifs</th><th>Créée le</th><th></th></tr></thead>
<tbody id="companies-tbody"></tbody>
</table>
</div>
</section>
<!-- USERS VIEW (admin) -->
<section id="view-users" class="view" hidden>
<div class="view-header">
<h1>Utilisateurs</h1>
<button id="add-user-btn" class="btn btn-primary">+ Nouvel utilisateur</button>
</div>
<div class="card">
<div class="filter-row">
<label>Société :
<select id="users-filter-company"><option value="">Toutes</option></select>
</label>
</div>
<table class="data-table">
<thead><tr><th>Nom</th><th>E-mail</th><th>Rôle</th><th>Société</th><th>Statut</th><th></th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</section>
<!-- PLANNINGS VIEW (admin browses any cadre calendar) -->
<section id="view-plannings" class="view" hidden>
<div class="view-header"><h1>Plannings</h1></div>
<div class="card selector-card">
<label>Société :
<select id="plannings-company-select"></select>
</label>
<label>Cadre :
<select id="plannings-cadre-select"></select>
</label>
<button id="plannings-view-btn" class="btn btn-primary">Afficher le planning</button>
</div>
</section>
<!-- VALIDATION & EXPORT VIEW (admin) -->
<section id="view-validation" class="view" hidden>
<div class="view-header"><h1>Validation &amp; export mensuel</h1></div>
<div class="card selector-card">
<label>Société :
<select id="validation-company-select"></select>
</label>
<label>Mois :
<select id="validation-month-select"></select>
</label>
<label>Année :
<select id="validation-year-select"></select>
</label>
<button id="validation-load-btn" class="btn btn-primary">Afficher</button>
</div>
<div id="validation-summary-card" class="card" hidden>
<div class="validation-summary-header">
<div>
<h2 id="validation-company-title"></h2>
<p id="validation-status-text" class="muted"></p>
</div>
<div class="validation-summary-actions">
<button id="validate-company-btn" class="btn btn-primary">Valider la société</button>
<button id="reopen-company-btn" class="btn btn-outline">Réouvrir</button>
</div>
</div>
<table class="data-table">
<thead><tr><th>Cadre</th><th>Statut</th><th>Validé le</th><th></th></tr></thead>
<tbody id="validation-cadres-tbody"></tbody>
</table>
<div class="export-row">
<span>Export :</span>
<button id="export-pdf-btn" class="btn btn-outline">Télécharger PDF</button>
<button id="export-excel-btn" class="btn btn-outline">Télécharger Excel</button>
</div>
</div>
</section>
</main>
</div>
<!-- STATUS POPOVER (day half-cell picker) -->
<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="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>
</div>
<!-- GENERIC MODAL -->
<div id="modal-backdrop" class="modal-backdrop" hidden>
<div class="modal" id="modal-box"></div>
</div>
<script src="js/app.js"></script>
</body>
</html>
+740
View File
@@ -0,0 +1,740 @@
(() => {
'use strict';
const MONTH_NAMES = [
'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 state = {
user: null,
companies: [],
cal: { year: null, month: null, targetUserId: null, targetUserName: null, data: null },
currentView: 'calendar',
};
// ---------- API helper ----------
async function api(path, options = {}) {
const res = await fetch(`/api${path}`, {
method: options.method || 'GET',
headers: options.body ? { 'Content-Type': 'application/json' } : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
credentials: 'include',
});
let data = null;
try { data = await res.json(); } catch (e) { /* no body */ }
if (!res.ok) {
const err = new Error((data && data.error) || `Erreur ${res.status}`);
err.status = res.status;
err.payload = data;
throw err;
}
return data;
}
function toast(message, type = 'info') {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('fr-FR') + ' à ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
// ---------- Auth ----------
async function checkSession() {
try {
const me = await api('/auth/me');
onLogin(me);
} catch (e) {
showLogin();
}
}
function showLogin() {
document.getElementById('view-login').hidden = false;
document.getElementById('app').hidden = true;
}
async function onLogin(user) {
state.user = user;
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');
const now = new Date();
state.cal.year = now.getFullYear();
state.cal.month = now.getMonth() + 1;
state.cal.targetUserId = null;
state.cal.targetUserName = null;
if (user.role === 'admin') {
document.getElementById('admin-nav').hidden = false;
await loadCompaniesForSelects();
switchView('companies');
} else {
document.getElementById('admin-nav').hidden = true;
switchView('calendar');
await loadCalendar();
}
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('login-email').value.trim();
const password = document.getElementById('login-password').value;
const errBox = document.getElementById('login-error');
errBox.hidden = true;
try {
const user = await api('/auth/login', { method: 'POST', body: { email, password } });
await onLogin(user);
} catch (err) {
errBox.textContent = err.message || 'Connexion impossible';
errBox.hidden = false;
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
state.user = null;
location.reload();
});
// ---------- View switching ----------
const VIEWS = ['calendar', 'companies', 'users', 'plannings', 'validation'];
function switchView(name) {
state.currentView = name;
VIEWS.forEach((v) => {
document.getElementById(`view-${v}`).hidden = v !== name;
});
document.querySelectorAll('.nav-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.view === name);
});
if (name === 'companies') loadCompaniesView();
if (name === 'users') loadUsersView();
if (name === 'plannings') loadPlanningsView();
if (name === 'validation') loadValidationSelectors();
}
document.getElementById('admin-nav').addEventListener('click', (e) => {
const btn = e.target.closest('.nav-btn');
if (!btn) return;
if (btn.dataset.view === 'calendar') {
state.cal.targetUserId = null;
state.cal.targetUserName = null;
document.getElementById('calendar-owner-name').textContent = 'Mon planning';
document.getElementById('calendar-actions').hidden = false;
switchView('calendar');
loadCalendar();
return;
}
switchView(btn.dataset.view);
});
// ================= CALENDAR =================
const monthLabel = document.getElementById('month-label');
const calGrid = document.getElementById('calendar-grid');
const validationBanner = document.getElementById('validation-banner');
const validateBtn = document.getElementById('validate-month-btn');
document.getElementById('prev-month').addEventListener('click', () => shiftMonth(-1));
document.getElementById('next-month').addEventListener('click', () => shiftMonth(1));
function shiftMonth(delta) {
let { year, month } = state.cal;
month += delta;
if (month < 1) { month = 12; year -= 1; }
if (month > 12) { month = 1; year += 1; }
state.cal.year = year;
state.cal.month = month;
loadCalendar();
}
async function loadCalendar() {
const { year, month, targetUserId } = state.cal;
monthLabel.textContent = `${MONTH_NAMES[month - 1]} ${year}`;
const qs = new URLSearchParams({ year, month });
if (targetUserId) qs.set('user_id', targetUserId);
try {
const data = await api(`/attendance?${qs.toString()}`);
state.cal.data = data;
renderCalendar(data);
} catch (err) {
toast(err.message, 'error');
}
}
function renderCalendar(data) {
const { year, month, targetUserId } = state.cal;
const entryMap = new Map();
data.entries.forEach((e) => entryMap.set(`${e.date}:${e.period}`, e.status));
// banner
const isAdmin = state.user.role === 'admin';
validationBanner.className = 'validation-banner';
if (data.companyValidated) {
validationBanner.classList.add('validated');
validationBanner.textContent = `Société validée par l'administrateur le ${fmtDate(data.companyValidatedAt)} — verrouillé`;
} else if (data.cadreValidated) {
validationBanner.classList.add('locked');
validationBanner.textContent = `Mois validé le ${fmtDate(data.cadreValidatedAt)} — en attente de validation société`;
} else {
validationBanner.textContent = 'Mois non validé';
}
// validate button only shown on own calendar (cadre role, not admin browsing someone else)
const actionsBox = document.getElementById('calendar-actions');
if (!isAdmin && !targetUserId) {
actionsBox.hidden = false;
validateBtn.disabled = data.cadreValidated || data.companyValidated;
validateBtn.textContent = data.cadreValidated ? 'Mois déjà validé' : 'Valider mon mois';
} else {
actionsBox.hidden = true;
}
const firstOfMonth = new Date(year, month - 1, 1);
const daysInMonth = new Date(year, month, 0).getDate();
let firstWeekday = firstOfMonth.getDay(); // 0=Sun
firstWeekday = firstWeekday === 0 ? 7 : firstWeekday; // 1=Mon..7=Sun
const leading = firstWeekday - 1;
calGrid.innerHTML = '';
for (let i = 0; i < leading; i++) {
const empty = document.createElement('div');
empty.className = 'day-cell empty';
calGrid.appendChild(empty);
}
const editable = data.editable;
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' : '');
const num = document.createElement('div');
num.className = 'day-number';
num.textContent = d;
cell.appendChild(num);
const row = document.createElement('div');
row.className = 'half-day-row';
['AM', 'PM'].forEach((period) => {
const status = entryMap.get(`${iso}:${period}`);
const half = document.createElement('button');
half.type = 'button';
half.className = 'half-day' + (status ? ` st-${status}` : '') + (editable ? '' : ' readonly');
half.textContent = period;
half.dataset.date = iso;
half.dataset.period = period;
half.dataset.status = status || '';
if (!editable) half.disabled = true;
row.appendChild(half);
});
cell.appendChild(row);
calGrid.appendChild(cell);
}
}
// ---- Half-day popover ----
const popover = document.getElementById('status-popover');
let popoverTarget = null;
calGrid.addEventListener('click', (e) => {
const half = e.target.closest('.half-day');
if (!half || half.disabled) return;
popoverTarget = half;
const label = half.dataset.period === 'AM' ? 'Matin' : 'Après-midi';
document.getElementById('popover-title').textContent = `${half.dataset.date} — ${label}`;
const rect = half.getBoundingClientRect();
popover.style.top = `${window.scrollY + rect.bottom + 6}px`;
popover.style.left = `${window.scrollX + rect.left}px`;
popover.hidden = false;
});
document.addEventListener('click', (e) => {
if (popover.hidden) return;
if (e.target.closest('.popover') || e.target.closest('.half-day')) return;
popover.hidden = true;
});
popover.addEventListener('click', async (e) => {
const opt = e.target.closest('.popover-option');
if (!opt || !popoverTarget) return;
const status = opt.dataset.status;
const { date, period } = popoverTarget.dataset;
popover.hidden = true;
try {
if (status) {
const body = { date, period, status };
if (state.cal.targetUserId) body.user_id = state.cal.targetUserId;
await api('/attendance', { method: 'PUT', body });
} else {
const body = { date, period };
if (state.cal.targetUserId) body.user_id = state.cal.targetUserId;
await api('/attendance', { method: 'DELETE', body });
}
await loadCalendar();
} catch (err) {
toast(err.message, 'error');
}
});
validateBtn.addEventListener('click', async () => {
if (!confirm('Confirmer la validation de votre mois ? Vous ne pourrez plus le modifier ensuite.')) return;
try {
await api('/validations/cadre', { method: 'POST', body: { year: state.cal.year, month: state.cal.month } });
toast('Mois validé avec succès', 'success');
await loadCalendar();
} catch (err) {
toast(err.message, 'error');
}
});
// ================= COMPANIES (admin) =================
async function loadCompaniesForSelects() {
try {
state.companies = await api('/companies');
} catch (err) {
toast(err.message, 'error');
}
}
async function loadCompaniesView() {
await loadCompaniesForSelects();
const tbody = document.getElementById('companies-tbody');
tbody.innerHTML = '';
state.companies.forEach((c) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(c.name)}</td>
<td>${c.active_cadre_count}</td>
<td>${new Date(c.created_at).toLocaleDateString('fr-FR')}</td>
<td class="row-actions">
<button class="btn btn-outline btn-sm" data-action="edit" data-id="${c.id}">Modifier</button>
<button class="btn btn-danger btn-sm" data-action="delete" data-id="${c.id}">Supprimer</button>
</td>`;
tbody.appendChild(tr);
});
}
document.getElementById('companies-tbody').addEventListener('click', async (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const id = btn.dataset.id;
const company = state.companies.find((c) => String(c.id) === id);
if (btn.dataset.action === 'edit') openCompanyModal(company);
if (btn.dataset.action === 'delete') {
if (!confirm(`Supprimer la société "${company.name}" ? Les cadres associés perdront leur société.`)) return;
try {
await api(`/companies/${id}`, { method: 'DELETE' });
toast('Société supprimée', 'success');
loadCompaniesView();
} catch (err) {
toast(err.message, 'error');
}
}
});
document.getElementById('add-company-btn').addEventListener('click', () => openCompanyModal(null));
function openCompanyModal(company) {
const isEdit = !!company;
openModal(`
<h2>${isEdit ? 'Modifier la société' : 'Nouvelle société'}</h2>
<form id="company-form">
<label class="field"><span>Nom</span>
<input type="text" id="company-name" required value="${isEdit ? escapeHtml(company.name) : ''}" />
</label>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" data-close>Annuler</button>
<button type="submit" class="btn btn-primary">${isEdit ? 'Enregistrer' : 'Créer'}</button>
</div>
</form>
`);
document.getElementById('company-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('company-name').value.trim();
try {
if (isEdit) await api(`/companies/${company.id}`, { method: 'PUT', body: { name } });
else await api('/companies', { method: 'POST', body: { name } });
closeModal();
toast('Société enregistrée', 'success');
loadCompaniesView();
} catch (err) {
toast(err.message, 'error');
}
});
}
// ================= USERS (admin) =================
async function loadUsersView() {
if (!state.companies.length) await loadCompaniesForSelects();
const companySelect = document.getElementById('users-filter-company');
companySelect.innerHTML = '<option value="">Toutes</option>' +
state.companies.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
await refreshUsersTable();
}
document.getElementById('users-filter-company').addEventListener('change', refreshUsersTable);
async function refreshUsersTable() {
const companyId = document.getElementById('users-filter-company').value;
const qs = companyId ? `?company_id=${companyId}` : '';
try {
const users = await api(`/users${qs}`);
state.users = users;
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = '';
users.forEach((u) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(u.full_name)}</td>
<td>${escapeHtml(u.email)}</td>
<td><span class="badge ${u.role === 'admin' ? 'badge-admin' : 'badge-cadre'}">${u.role === 'admin' ? 'Admin' : 'Cadre'}</span></td>
<td>${u.company_name ? escapeHtml(u.company_name) : '—'}</td>
<td><span class="badge ${u.active ? 'badge-active' : 'badge-inactive'}">${u.active ? 'Actif' : 'Inactif'}</span></td>
<td class="row-actions">
<button class="btn btn-outline btn-sm" data-action="edit" data-id="${u.id}">Modifier</button>
<button class="btn btn-outline btn-sm" data-action="password" data-id="${u.id}">Mot de passe</button>
<button class="btn btn-danger btn-sm" data-action="delete" data-id="${u.id}">Supprimer</button>
</td>`;
tbody.appendChild(tr);
});
} catch (err) {
toast(err.message, 'error');
}
}
document.getElementById('users-tbody').addEventListener('click', async (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const id = btn.dataset.id;
const user = state.users.find((u) => String(u.id) === id);
if (btn.dataset.action === 'edit') openUserModal(user);
if (btn.dataset.action === 'password') openPasswordModal(user);
if (btn.dataset.action === 'delete') {
if (!confirm(`Supprimer l'utilisateur "${user.full_name}" ?`)) return;
try {
await api(`/users/${id}`, { method: 'DELETE' });
toast('Utilisateur supprimé', 'success');
refreshUsersTable();
} catch (err) {
toast(err.message, 'error');
}
}
});
document.getElementById('add-user-btn').addEventListener('click', () => openUserModal(null));
function companyOptions(selectedId) {
return state.companies.map((c) =>
`<option value="${c.id}" ${String(c.id) === String(selectedId) ? 'selected' : ''}>${escapeHtml(c.name)}</option>`
).join('');
}
function openUserModal(user) {
const isEdit = !!user;
openModal(`
<h2>${isEdit ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur'}</h2>
<form id="user-form">
<label class="field"><span>Nom complet</span>
<input type="text" id="user-fullname" required value="${isEdit ? escapeHtml(user.full_name) : ''}" />
</label>
<label class="field"><span>E-mail</span>
<input type="email" id="user-email" required value="${isEdit ? escapeHtml(user.email) : ''}" />
</label>
${!isEdit ? `
<label class="field"><span>Mot de passe</span>
<input type="password" id="user-password" required minlength="6" />
</label>` : ''}
<label class="field"><span>Rôle</span>
<select id="user-role">
<option value="cadre" ${(!isEdit || user.role === 'cadre') ? 'selected' : ''}>Cadre</option>
<option value="admin" ${isEdit && user.role === 'admin' ? 'selected' : ''}>Administrateur</option>
</select>
</label>
<label class="field" id="user-company-field"><span>Société</span>
<select id="user-company">${companyOptions(isEdit ? user.company_id : null)}</select>
</label>
${isEdit ? `
<label class="field"><span>Statut</span>
<select id="user-active">
<option value="true" ${user.active ? 'selected' : ''}>Actif</option>
<option value="false" ${!user.active ? 'selected' : ''}>Inactif</option>
</select>
</label>` : ''}
<div class="modal-actions">
<button type="button" class="btn btn-ghost" data-close>Annuler</button>
<button type="submit" class="btn btn-primary">${isEdit ? 'Enregistrer' : 'Créer'}</button>
</div>
</form>
`);
const roleSelect = document.getElementById('user-role');
const companyField = document.getElementById('user-company-field');
function toggleCompanyField() {
companyField.style.display = roleSelect.value === 'admin' ? 'none' : 'block';
}
roleSelect.addEventListener('change', toggleCompanyField);
toggleCompanyField();
document.getElementById('user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
full_name: document.getElementById('user-fullname').value.trim(),
email: document.getElementById('user-email').value.trim(),
role: roleSelect.value,
company_id: roleSelect.value === 'admin' ? null : document.getElementById('user-company').value,
};
if (!isEdit) body.password = document.getElementById('user-password').value;
if (isEdit) body.active = document.getElementById('user-active').value === 'true';
try {
if (isEdit) await api(`/users/${user.id}`, { method: 'PUT', body });
else await api('/users', { method: 'POST', body });
closeModal();
toast('Utilisateur enregistré', 'success');
refreshUsersTable();
} catch (err) {
toast(err.message, 'error');
}
});
}
function openPasswordModal(user) {
openModal(`
<h2>Nouveau mot de passe</h2>
<p class="muted">Pour ${escapeHtml(user.full_name)}</p>
<form id="password-form">
<label class="field"><span>Mot de passe</span>
<input type="password" id="new-password" required minlength="6" />
</label>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" data-close>Annuler</button>
<button type="submit" class="btn btn-primary">Mettre à jour</button>
</div>
</form>
`);
document.getElementById('password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('new-password').value;
try {
await api(`/users/${user.id}/password`, { method: 'PUT', body: { password } });
closeModal();
toast('Mot de passe mis à jour', 'success');
} catch (err) {
toast(err.message, 'error');
}
});
}
// ================= PLANNINGS (admin browse) =================
async function loadPlanningsView() {
if (!state.companies.length) await loadCompaniesForSelects();
const companySelect = document.getElementById('plannings-company-select');
companySelect.innerHTML = state.companies.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
await refreshPlanningsCadres();
}
document.getElementById('plannings-company-select').addEventListener('change', refreshPlanningsCadres);
async function refreshPlanningsCadres() {
const companyId = document.getElementById('plannings-company-select').value;
const cadreSelect = document.getElementById('plannings-cadre-select');
if (!companyId) { cadreSelect.innerHTML = ''; return; }
try {
const users = await api(`/users?company_id=${companyId}`);
const cadres = users.filter((u) => u.role === 'cadre');
cadreSelect.innerHTML = cadres.map((u) => `<option value="${u.id}">${escapeHtml(u.full_name)}</option>`).join('') ||
'<option value="">Aucun cadre</option>';
} catch (err) {
toast(err.message, 'error');
}
}
document.getElementById('plannings-view-btn').addEventListener('click', async () => {
const cadreSelect = document.getElementById('plannings-cadre-select');
const userId = cadreSelect.value;
if (!userId) { toast('Sélectionnez un cadre', 'error'); return; }
const name = cadreSelect.options[cadreSelect.selectedIndex].textContent;
state.cal.targetUserId = userId;
state.cal.targetUserName = name;
document.getElementById('calendar-owner-name').textContent = `Planning de ${name}`;
switchView('calendar');
await loadCalendar();
});
// ================= VALIDATION & EXPORT (admin) =================
function loadValidationSelectors() {
if (!state.companies.length) return loadCompaniesForSelects().then(loadValidationSelectors);
const companySelect = document.getElementById('validation-company-select');
if (!companySelect.dataset.filled) {
companySelect.innerHTML = state.companies.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
companySelect.dataset.filled = '1';
}
const monthSelect = document.getElementById('validation-month-select');
if (!monthSelect.dataset.filled) {
monthSelect.innerHTML = MONTH_NAMES.map((m, i) => `<option value="${i + 1}">${m}</option>`).join('');
monthSelect.value = new Date().getMonth() + 1;
monthSelect.dataset.filled = '1';
}
const yearSelect = document.getElementById('validation-year-select');
if (!yearSelect.dataset.filled) {
const currentYear = new Date().getFullYear();
const years = [];
for (let y = currentYear - 2; y <= currentYear + 1; y++) years.push(y);
yearSelect.innerHTML = years.map((y) => `<option value="${y}">${y}</option>`).join('');
yearSelect.value = currentYear;
yearSelect.dataset.filled = '1';
}
}
document.getElementById('validation-load-btn').addEventListener('click', loadValidationSummary);
let currentValidationCompanyId = null;
let currentValidationYear = null;
let currentValidationMonth = null;
async function loadValidationSummary() {
const companyId = document.getElementById('validation-company-select').value;
const year = document.getElementById('validation-year-select').value;
const month = document.getElementById('validation-month-select').value;
currentValidationCompanyId = companyId;
currentValidationYear = year;
currentValidationMonth = month;
try {
const data = await api(`/validations/company/${companyId}?year=${year}&month=${month}`);
const company = state.companies.find((c) => String(c.id) === companyId);
document.getElementById('validation-summary-card').hidden = false;
document.getElementById('validation-company-title').textContent =
`${company ? company.name : ''} — ${MONTH_NAMES[month - 1]} ${year}`;
const statusText = document.getElementById('validation-status-text');
if (data.companyValidated) {
statusText.textContent = `Société validée le ${fmtDate(data.companyValidatedAt)}`;
} else if (data.allCadresValidated) {
statusText.textContent = 'Tous les cadres ont validé — prêt pour validation société';
} else {
const pending = data.cadres.filter((c) => !c.cadreValidated).map((c) => c.fullName);
statusText.textContent = pending.length
? `En attente de validation : ${pending.join(', ')}`
: 'Aucun cadre actif dans cette société';
}
document.getElementById('validate-company-btn').disabled = data.companyValidated || !data.allCadresValidated;
document.getElementById('reopen-company-btn').disabled = !data.companyValidated;
const tbody = document.getElementById('validation-cadres-tbody');
tbody.innerHTML = '';
data.cadres.forEach((c) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(c.fullName)}</td>
<td><span class="badge ${c.cadreValidated ? 'badge-validated' : 'badge-pending'}">${c.cadreValidated ? 'Validé' : 'En attente'}</span></td>
<td>${c.cadreValidatedAt ? fmtDate(c.cadreValidatedAt) : '—'}</td>
<td class="row-actions">
<button class="btn btn-outline btn-sm" data-action="reopen-cadre" data-id="${c.id}" ${data.companyValidated ? 'disabled' : ''}>Réouvrir</button>
</td>`;
tbody.appendChild(tr);
});
} catch (err) {
toast(err.message, 'error');
}
}
document.getElementById('validation-cadres-tbody').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action="reopen-cadre"]');
if (!btn) return;
if (!confirm('Réouvrir le mois de ce cadre pour permettre des corrections ?')) return;
try {
await api('/validations/cadre/reopen', {
method: 'POST',
body: { user_id: btn.dataset.id, year: currentValidationYear, month: currentValidationMonth },
});
toast('Mois réouvert pour ce cadre', 'success');
loadValidationSummary();
} catch (err) {
toast(err.message, 'error');
}
});
document.getElementById('validate-company-btn').addEventListener('click', async () => {
if (!confirm('Valider définitivement cette société pour ce mois ? Les plannings seront verrouillés.')) return;
try {
await api(`/validations/company/${currentValidationCompanyId}`, {
method: 'POST',
body: { year: currentValidationYear, month: currentValidationMonth },
});
toast('Société validée', 'success');
loadValidationSummary();
} catch (err) {
toast(err.message, 'error');
}
});
document.getElementById('reopen-company-btn').addEventListener('click', async () => {
if (!confirm('Réouvrir la validation de cette société pour ce mois ?')) return;
try {
await api(`/validations/company/${currentValidationCompanyId}/reopen`, {
method: 'POST',
body: { year: currentValidationYear, month: currentValidationMonth },
});
toast('Validation société réouverte', 'success');
loadValidationSummary();
} catch (err) {
toast(err.message, 'error');
}
});
document.getElementById('export-pdf-btn').addEventListener('click', () => downloadExport('pdf'));
document.getElementById('export-excel-btn').addEventListener('click', () => downloadExport('excel'));
function downloadExport(kind) {
if (!currentValidationCompanyId) return;
const url = `/api/export/${kind}/${currentValidationCompanyId}?year=${currentValidationYear}&month=${currentValidationMonth}`;
const a = document.createElement('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
// ================= Modal helpers =================
const modalBackdrop = document.getElementById('modal-backdrop');
const modalBox = document.getElementById('modal-box');
function openModal(html) {
modalBox.innerHTML = html;
modalBackdrop.hidden = false;
}
function closeModal() {
modalBackdrop.hidden = true;
modalBox.innerHTML = '';
}
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop || e.target.closest('[data-close]')) closeModal();
});
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str == null ? '' : String(str);
return div.innerHTML;
}
// ---------- Boot ----------
checkSession();
})();