mirror of
https://github.com/R0m1k3/Presencia.git
synced 2026-10-11 17:27:42 +02:00
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:
27 files changed
+5492
No files matched your search
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in new issue
Block a user