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,18 @@
|
||||
# Ports d'accès (volontairement non standards)
|
||||
FRONTEND_HOST_PORT=8781
|
||||
BACKEND_HOST_PORT=4790
|
||||
DB_HOST_PORT=6543
|
||||
|
||||
# Base de données
|
||||
POSTGRES_USER=presencia
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=presencia
|
||||
|
||||
# Sécurité
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Compte administrateur créé au premier démarrage
|
||||
ADMIN_EMAIL=admin@presencia.local
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
ADMIN_NAME=Administrateur
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,79 @@
|
||||
# Presencia
|
||||
|
||||
Application de gestion des présences par demi-journée, multi-sociétés, avec validation mensuelle en deux étapes (cadre puis administrateur) et export PDF/Excel par société.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Connexion uniquement** : aucune inscription publique. Tous les comptes (admin et cadres) sont créés par un administrateur.
|
||||
- **Multi-sociétés** : chaque cadre est rattaché à une société. L'administrateur voit toutes les sociétés et tous les plannings ; un cadre ne voit que le sien.
|
||||
- **Planning mensuel** : vue calendrier, clic sur une demi-journée (matin/après-midi) pour choisir : Présent, Absent, Congé, RTT.
|
||||
- **Validation de fin de mois en deux temps** :
|
||||
1. Chaque cadre **valide son propre mois** (verrouille ses saisies).
|
||||
2. Une fois **tous les cadres d'une société** validés, l'administrateur **valide la société** pour ce mois (verrouillage complet). L'administrateur peut réouvrir un mois cadre ou une société en cas de correction nécessaire.
|
||||
- **Export** PDF et Excel par société et par mois (détail par cadre, jour et demi-journée).
|
||||
- **Docker** : Postgres + API Node/Express + frontend Nginx, sur des ports non standards.
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# éditer .env : mot de passe DB, JWT_SECRET, identifiants admin par défaut
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Ports par défaut (modifiables dans `.env`) :
|
||||
|
||||
| Service | Port hôte |
|
||||
|-----------|-----------|
|
||||
| Frontend | 8781 |
|
||||
| API | 4790 |
|
||||
| Postgres | 6543 |
|
||||
|
||||
Ouvrir http://localhost:8781
|
||||
|
||||
Un compte administrateur est créé automatiquement au premier démarrage avec les identifiants définis par `ADMIN_EMAIL` / `ADMIN_PASSWORD` dans `.env` (par défaut `admin@presencia.local` / `ChangeMe123!`). **Changez ce mot de passe après la première connexion** (aucune page de changement de mot de passe en libre-service n'est fournie côté cadre ; un administrateur peut réinitialiser le mot de passe de n'importe quel compte depuis l'onglet Utilisateurs).
|
||||
|
||||
## Utilisation
|
||||
|
||||
### En tant qu'administrateur
|
||||
|
||||
- **Sociétés** : créer / renommer / supprimer les sociétés.
|
||||
- **Utilisateurs** : créer des comptes cadre ou admin, attribuer une société à un cadre, réinitialiser un mot de passe, activer/désactiver un compte.
|
||||
- **Plannings** : consulter (et corriger si besoin) le planning de n'importe quel cadre.
|
||||
- **Validation & export** : pour une société et un mois donnés, suivre la validation de chaque cadre, valider la société une fois tous les cadres validés, réouvrir si besoin, télécharger le PDF ou l'Excel récapitulatif.
|
||||
|
||||
### En tant que cadre
|
||||
|
||||
- Renseigner sa présence par demi-journée sur le mois en cours (ou les mois précédents/suivants).
|
||||
- En fin de mois, cliquer sur **"Valider mon mois"** : les saisies sont alors verrouillées et transmises pour validation à l'administrateur. Si une correction est nécessaire après coup, il faut qu'un administrateur réouvre le mois.
|
||||
|
||||
## Architecture technique
|
||||
|
||||
```
|
||||
backend/ API Node.js / Express, PostgreSQL (pg), auth par cookie JWT httpOnly
|
||||
frontend/ Page HTML unique (vanilla JS), servie par Nginx qui proxifie /api vers le backend
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
### Modèle de données (PostgreSQL)
|
||||
|
||||
- `companies` — sociétés
|
||||
- `users` — comptes (rôle `admin` ou `cadre`, société associée pour les cadres)
|
||||
- `attendance_entries` — une ligne par utilisateur / date / demi-journée (AM ou PM) / statut
|
||||
- `month_locks` — validation du mois par le cadre (`cadre_validated`)
|
||||
- `company_month_validations` — validation du mois par l'administrateur pour une société entière (`admin_validated`)
|
||||
|
||||
Règles de verrouillage :
|
||||
- Un cadre ne peut plus modifier ses saisies une fois son mois validé (`month_locks.cadre_validated = true`), tant qu'un administrateur ne l'a pas réouvert.
|
||||
- Personne (y compris l'administrateur) ne peut modifier les saisies d'une société dont le mois est validé (`company_month_validations.admin_validated = true`), tant qu'il n'a pas été réouvert.
|
||||
- L'administrateur ne peut valider une société pour un mois donné que si **tous** les cadres actifs de cette société ont déjà validé leur mois.
|
||||
|
||||
## Développement local (sans Docker)
|
||||
|
||||
```bash
|
||||
cd backend && npm install
|
||||
# variables d'env : voir backend/.env.example (pointer PGHOST vers un Postgres local)
|
||||
npm start
|
||||
```
|
||||
|
||||
Le frontend est un simple dossier de fichiers statiques (`frontend/public`) ; en développement, servez-le avec n'importe quel serveur statique qui proxifie `/api` vers `http://localhost:4790` (voir `frontend/nginx.conf` pour la configuration de référence).
|
||||
@@ -0,0 +1,14 @@
|
||||
PORT=4790
|
||||
|
||||
PGHOST=db
|
||||
PGPORT=5432
|
||||
PGUSER=presencia
|
||||
PGPASSWORD=presencia
|
||||
PGDATABASE=presencia
|
||||
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
COOKIE_SECURE=false
|
||||
|
||||
ADMIN_EMAIL=admin@presencia.local
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
ADMIN_NAME=Administrateur
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV PORT=4790
|
||||
EXPOSE 4790
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Presencia schema
|
||||
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'cadre');
|
||||
CREATE TYPE half_day_period AS ENUM ('AM', 'PM');
|
||||
CREATE TYPE attendance_status AS ENUM ('present', 'absent', 'conge', 'rtt');
|
||||
|
||||
CREATE TABLE companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role user_role NOT NULL DEFAULT 'cadre',
|
||||
company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_company ON users(company_id);
|
||||
|
||||
CREATE TABLE attendance_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entry_date DATE NOT NULL,
|
||||
period half_day_period NOT NULL,
|
||||
status attendance_status NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(user_id, entry_date, period)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attendance_user_date ON attendance_entries(user_id, entry_date);
|
||||
|
||||
-- Cadre self-validation of a given month (locks that month's entries for the cadre)
|
||||
CREATE TABLE month_locks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
|
||||
cadre_validated BOOLEAN NOT NULL DEFAULT false,
|
||||
cadre_validated_at TIMESTAMPTZ,
|
||||
UNIQUE(user_id, year, month)
|
||||
);
|
||||
|
||||
-- Admin validation of a whole company for a given month (requires all cadres validated first)
|
||||
CREATE TABLE company_month_validations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
|
||||
admin_validated BOOLEAN NOT NULL DEFAULT false,
|
||||
admin_validated_at TIMESTAMPTZ,
|
||||
admin_validated_by INTEGER REFERENCES users(id),
|
||||
UNIQUE(company_id, year, month)
|
||||
);
|
||||
Generated
+2926
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "presencia-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pdfkit": "^0.15.0",
|
||||
"pg": "^8.12.0"
|
||||
}
|
||||
}
|
||||
Vendored
+70
@@ -0,0 +1,70 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('./db');
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForDb(retries = 30, delayMs = 2000) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await db.query('SELECT 1');
|
||||
return;
|
||||
} catch (err) {
|
||||
console.log(`En attente de la base de données... (${i + 1}/${retries})`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
throw new Error('Impossible de se connecter à la base de données');
|
||||
}
|
||||
|
||||
async function runMigrations() {
|
||||
const { rows } = await db.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables WHERE table_name = 'companies'
|
||||
) AS exists`
|
||||
);
|
||||
if (rows[0].exists) {
|
||||
console.log('Schéma déjà initialisé.');
|
||||
return;
|
||||
}
|
||||
const sqlPath = path.join(__dirname, '..', 'migrations', '001_init.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
console.log('Initialisation du schéma de base de données...');
|
||||
await db.query(sql);
|
||||
console.log('Schéma créé.');
|
||||
}
|
||||
|
||||
async function seedAdmin() {
|
||||
const { rows } = await db.query(
|
||||
"SELECT id FROM users WHERE role = 'admin' LIMIT 1"
|
||||
);
|
||||
if (rows.length > 0) return;
|
||||
|
||||
const email = process.env.ADMIN_EMAIL || 'admin@presencia.local';
|
||||
const password = process.env.ADMIN_PASSWORD || 'ChangeMe123!';
|
||||
const fullName = process.env.ADMIN_NAME || 'Administrateur';
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await db.query(
|
||||
`INSERT INTO users (full_name, email, password_hash, role, company_id, active)
|
||||
VALUES ($1, $2, $3, 'admin', NULL, true)`,
|
||||
[fullName, email, hash]
|
||||
);
|
||||
console.log('========================================================');
|
||||
console.log(' Compte administrateur créé :');
|
||||
console.log(` Email : ${email}`);
|
||||
console.log(` Mot de passe : ${password}`);
|
||||
console.log(' Merci de le changer après la première connexion.');
|
||||
console.log('========================================================');
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await waitForDb();
|
||||
await runMigrations();
|
||||
await seedAdmin();
|
||||
}
|
||||
|
||||
module.exports = bootstrap;
|
||||
@@ -0,0 +1,14 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.PGHOST || 'db',
|
||||
port: parseInt(process.env.PGPORT || '5432', 10),
|
||||
user: process.env.PGUSER || 'presencia',
|
||||
password: process.env.PGPASSWORD || 'presencia',
|
||||
database: process.env.PGDATABASE || 'presencia',
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
query: (text, params) => pool.query(text, params),
|
||||
pool,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const cors = require('cors');
|
||||
|
||||
const bootstrap = require('./bootstrap');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const companyRoutes = require('./routes/companies');
|
||||
const userRoutes = require('./routes/users');
|
||||
const attendanceRoutes = require('./routes/attendance');
|
||||
const validationRoutes = require('./routes/validations');
|
||||
const exportRoutes = require('./routes/export');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4790;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
if (process.env.CORS_ORIGIN) {
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true }));
|
||||
}
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/companies', companyRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/attendance', attendanceRoutes);
|
||||
app.use('/api/validations', validationRoutes);
|
||||
app.use('/api/export', exportRoutes);
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Erreur interne du serveur' });
|
||||
});
|
||||
|
||||
bootstrap()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Presencia API démarrée sur le port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Échec du démarrage :', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
|
||||
const COOKIE_NAME = 'presencia_token';
|
||||
|
||||
function signToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
companyId: user.company_id,
|
||||
fullName: user.full_name,
|
||||
email: user.email,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '12h' }
|
||||
);
|
||||
}
|
||||
|
||||
function setAuthCookie(res, token) {
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
maxAge: 12 * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
function clearAuthCookie(res) {
|
||||
res.clearCookie(COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const token = req.cookies && req.cookies[COOKIE_NAME];
|
||||
if (!token) return res.status(401).json({ error: 'Non authentifié' });
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Session invalide ou expirée' });
|
||||
}
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Accès réservé aux administrateurs' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
signToken,
|
||||
setAuthCookie,
|
||||
clearAuthCookie,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
COOKIE_NAME,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const STATUSES = ['present', 'absent', 'conge', 'rtt'];
|
||||
const PERIODS = ['AM', 'PM'];
|
||||
|
||||
async function getTargetUser(req, requestedUserId) {
|
||||
// Cadre can only ever act on themselves. Admin may act on any user.
|
||||
if (req.user.role === 'admin' && requestedUserId) {
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, role, company_id FROM users WHERE id = $1',
|
||||
[requestedUserId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
return { id: req.user.id, role: req.user.role, company_id: req.user.companyId };
|
||||
}
|
||||
|
||||
async function getMonthLock(userId, year, month) {
|
||||
const { rows } = await db.query(
|
||||
'SELECT cadre_validated, cadre_validated_at FROM month_locks WHERE user_id = $1 AND year = $2 AND month = $3',
|
||||
[userId, year, month]
|
||||
);
|
||||
return rows[0] || { cadre_validated: false, cadre_validated_at: null };
|
||||
}
|
||||
|
||||
async function getCompanyValidation(companyId, year, month) {
|
||||
if (!companyId) return { admin_validated: false, admin_validated_at: null };
|
||||
const { rows } = await db.query(
|
||||
'SELECT admin_validated, admin_validated_at FROM company_month_validations WHERE company_id = $1 AND year = $2 AND month = $3',
|
||||
[companyId, year, month]
|
||||
);
|
||||
return rows[0] || { admin_validated: false, admin_validated_at: null };
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
||||
|
||||
const requestedUserId = req.query.user_id;
|
||||
if (requestedUserId && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Accès refusé' });
|
||||
}
|
||||
const target = await getTargetUser(req, requestedUserId);
|
||||
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
|
||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const { rows } = await db.query(
|
||||
`SELECT entry_date, period, status
|
||||
FROM attendance_entries
|
||||
WHERE user_id = $1
|
||||
AND entry_date >= $2::date
|
||||
AND entry_date < ($2::date + INTERVAL '1 month')
|
||||
ORDER BY entry_date, period`,
|
||||
[target.id, start]
|
||||
);
|
||||
|
||||
const lock = await getMonthLock(target.id, year, month);
|
||||
const companyValidation = await getCompanyValidation(target.company_id, year, month);
|
||||
|
||||
res.json({
|
||||
userId: target.id,
|
||||
entries: rows.map((r) => ({
|
||||
date: r.entry_date.toISOString().slice(0, 10),
|
||||
period: r.period,
|
||||
status: r.status,
|
||||
})),
|
||||
cadreValidated: lock.cadre_validated,
|
||||
cadreValidatedAt: lock.cadre_validated_at,
|
||||
companyValidated: companyValidation.admin_validated,
|
||||
companyValidatedAt: companyValidation.admin_validated_at,
|
||||
editable:
|
||||
req.user.role === 'admin'
|
||||
? !companyValidation.admin_validated
|
||||
: !lock.cadre_validated && !companyValidation.admin_validated,
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/', async (req, res) => {
|
||||
const { date, period, status, user_id: requestedUserId } = req.body || {};
|
||||
if (!date || !PERIODS.includes(period) || !STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: 'Paramètres invalides' });
|
||||
}
|
||||
if (requestedUserId && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Accès refusé' });
|
||||
}
|
||||
const target = await getTargetUser(req, requestedUserId);
|
||||
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
|
||||
const d = new Date(date + 'T00:00:00Z');
|
||||
const year = d.getUTCFullYear();
|
||||
const month = d.getUTCMonth() + 1;
|
||||
|
||||
const lock = await getMonthLock(target.id, year, month);
|
||||
const companyValidation = await getCompanyValidation(target.company_id, year, month);
|
||||
|
||||
if (companyValidation.admin_validated) {
|
||||
return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
|
||||
}
|
||||
if (req.user.role !== 'admin' && lock.cadre_validated) {
|
||||
return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (user_id, entry_date, period)
|
||||
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
|
||||
[target.id, date, period, status]
|
||||
);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/', async (req, res) => {
|
||||
const { date, period, user_id: requestedUserId } = req.body || {};
|
||||
if (!date || !PERIODS.includes(period)) {
|
||||
return res.status(400).json({ error: 'Paramètres invalides' });
|
||||
}
|
||||
if (requestedUserId && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Accès refusé' });
|
||||
}
|
||||
const target = await getTargetUser(req, requestedUserId);
|
||||
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
|
||||
const d = new Date(date + 'T00:00:00Z');
|
||||
const year = d.getUTCFullYear();
|
||||
const month = d.getUTCMonth() + 1;
|
||||
const lock = await getMonthLock(target.id, year, month);
|
||||
const companyValidation = await getCompanyValidation(target.company_id, year, month);
|
||||
|
||||
if (companyValidation.admin_validated) {
|
||||
return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
|
||||
}
|
||||
if (req.user.role !== 'admin' && lock.cadre_validated) {
|
||||
return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
|
||||
}
|
||||
|
||||
await db.query(
|
||||
'DELETE FROM attendance_entries WHERE user_id = $1 AND entry_date = $2 AND period = $3',
|
||||
[target.id, date, period]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,66 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
const { signToken, setAuthCookie, clearAuthCookie, requireAuth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email et mot de passe requis' });
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
`SELECT u.id, u.full_name, u.email, u.password_hash, u.role, u.active,
|
||||
u.company_id, c.name AS company_name
|
||||
FROM users u
|
||||
LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE lower(u.email) = lower($1)`,
|
||||
[email]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || !user.active) {
|
||||
return res.status(401).json({ error: 'Identifiants incorrects' });
|
||||
}
|
||||
const ok = await bcrypt.compare(password, user.password_hash);
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Identifiants incorrects' });
|
||||
}
|
||||
const token = signToken(user);
|
||||
setAuthCookie(res, token);
|
||||
res.json({
|
||||
id: user.id,
|
||||
fullName: user.full_name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
companyId: user.company_id,
|
||||
companyName: user.company_name,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
clearAuthCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
const { rows } = await db.query(
|
||||
`SELECT u.id, u.full_name, u.email, u.role, u.company_id, c.name AS company_name
|
||||
FROM users u
|
||||
LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE u.id = $1`,
|
||||
[req.user.id]
|
||||
);
|
||||
if (!rows[0]) return res.status(401).json({ error: 'Non authentifié' });
|
||||
const u = rows[0];
|
||||
res.json({
|
||||
id: u.id,
|
||||
fullName: u.full_name,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
companyId: u.company_id,
|
||||
companyName: u.company_name,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,57 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { rows } = await db.query(
|
||||
`SELECT c.id, c.name, c.created_at,
|
||||
COUNT(u.id) FILTER (WHERE u.role = 'cadre' AND u.active) AS active_cadre_count
|
||||
FROM companies c
|
||||
LEFT JOIN users u ON u.company_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name`
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Nom requis' });
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO companies (name) VALUES ($1) RETURNING id, name, created_at',
|
||||
[name.trim()]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Cette société existe déjà' });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Nom requis' });
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
'UPDATE companies SET name = $1 WHERE id = $2 RETURNING id, name, created_at',
|
||||
[name.trim(), req.params.id]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Société introuvable' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Cette société existe déjà' });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { rowCount } = await db.query('DELETE FROM companies WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Société introuvable' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,81 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
const { buildCompanyWorkbook } = require('../utils/excel');
|
||||
const { buildCompanyPdf } = require('../utils/pdf');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
async function loadCompanyData(companyId, year, month) {
|
||||
const { rows: companyRows } = await db.query('SELECT id, name FROM companies WHERE id = $1', [companyId]);
|
||||
const company = companyRows[0];
|
||||
if (!company) return null;
|
||||
|
||||
const { rows: cadreRows } = await db.query(
|
||||
`SELECT id, full_name FROM users WHERE company_id = $1 AND role = 'cadre' AND active = true ORDER BY full_name`,
|
||||
[companyId]
|
||||
);
|
||||
|
||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const cadres = [];
|
||||
for (const c of cadreRows) {
|
||||
const { rows: entries } = await db.query(
|
||||
`SELECT entry_date, period, status FROM attendance_entries
|
||||
WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`,
|
||||
[c.id, start]
|
||||
);
|
||||
const map = new Map();
|
||||
for (const e of entries) {
|
||||
map.set(`${e.entry_date.toISOString().slice(0, 10)}:${e.period}`, e.status);
|
||||
}
|
||||
cadres.push({ id: c.id, fullName: c.full_name, entries: map });
|
||||
}
|
||||
|
||||
return { company, cadres };
|
||||
}
|
||||
|
||||
router.get('/excel/:companyId', async (req, res) => {
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
||||
|
||||
const data = await loadCompanyData(req.params.companyId, year, month);
|
||||
if (!data) return res.status(404).json({ error: 'Société introuvable' });
|
||||
|
||||
const workbook = await buildCompanyWorkbook({
|
||||
companyName: data.company.name,
|
||||
year,
|
||||
month,
|
||||
cadres: data.cadres,
|
||||
});
|
||||
|
||||
const filename = `presences_${data.company.name.replace(/\s+/g, '_')}_${year}-${String(month).padStart(2, '0')}.xlsx`;
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
});
|
||||
|
||||
router.get('/pdf/:companyId', async (req, res) => {
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
||||
|
||||
const data = await loadCompanyData(req.params.companyId, year, month);
|
||||
if (!data) return res.status(404).json({ error: 'Société introuvable' });
|
||||
|
||||
const filename = `presences_${data.company.name.replace(/\s+/g, '_')}_${year}-${String(month).padStart(2, '0')}.pdf`;
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
const doc = buildCompanyPdf({
|
||||
companyName: data.company.name,
|
||||
year,
|
||||
month,
|
||||
cadres: data.cadres,
|
||||
});
|
||||
doc.pipe(res);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,111 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { company_id } = req.query;
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (company_id) {
|
||||
params.push(company_id);
|
||||
where = `WHERE u.company_id = $${params.length}`;
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
`SELECT u.id, u.full_name, u.email, u.role, u.company_id, u.active, u.created_at,
|
||||
c.name AS company_name
|
||||
FROM users u
|
||||
LEFT JOIN companies c ON c.id = u.company_id
|
||||
${where}
|
||||
ORDER BY u.full_name`,
|
||||
params
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { full_name, email, password, role, company_id } = req.body || {};
|
||||
if (!full_name || !email || !password || !role) {
|
||||
return res.status(400).json({ error: 'Champs requis manquants' });
|
||||
}
|
||||
if (!['admin', 'cadre'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Rôle invalide' });
|
||||
}
|
||||
if (role === 'cadre' && !company_id) {
|
||||
return res.status(400).json({ error: 'Une société doit être attribuée au cadre' });
|
||||
}
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO users (full_name, email, password_hash, role, company_id, active)
|
||||
VALUES ($1, $2, $3, $4, $5, true)
|
||||
RETURNING id, full_name, email, role, company_id, active, created_at`,
|
||||
[full_name.trim(), email.trim().toLowerCase(), hash, role, role === 'admin' ? null : company_id]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { full_name, email, role, company_id, active } = req.body || {};
|
||||
if (!full_name || !email || !role) {
|
||||
return res.status(400).json({ error: 'Champs requis manquants' });
|
||||
}
|
||||
if (!['admin', 'cadre'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Rôle invalide' });
|
||||
}
|
||||
if (role === 'cadre' && !company_id) {
|
||||
return res.status(400).json({ error: 'Une société doit être attribuée au cadre' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
`UPDATE users SET full_name = $1, email = $2, role = $3, company_id = $4, active = $5
|
||||
WHERE id = $6
|
||||
RETURNING id, full_name, email, role, company_id, active, created_at`,
|
||||
[
|
||||
full_name.trim(),
|
||||
email.trim().toLowerCase(),
|
||||
role,
|
||||
role === 'admin' ? null : company_id,
|
||||
active !== false,
|
||||
req.params.id,
|
||||
]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/password', async (req, res) => {
|
||||
const { password } = req.body || {};
|
||||
if (!password || password.length < 6) {
|
||||
return res.status(400).json({ error: 'Mot de passe trop court (6 caractères minimum)' });
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const { rowCount } = await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [
|
||||
hash,
|
||||
req.params.id,
|
||||
]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
if (String(req.user.id) === String(req.params.id)) {
|
||||
return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' });
|
||||
}
|
||||
const { rowCount } = await db.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,146 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
function parseYearMonth(req) {
|
||||
const year = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
const month = parseInt(req.body?.month ?? req.query?.month, 10);
|
||||
if (!year || !month || month < 1 || month > 12) return null;
|
||||
return { year, month };
|
||||
}
|
||||
|
||||
// Cadre validates their own month.
|
||||
router.post('/cadre', async (req, res) => {
|
||||
const ym = parseYearMonth(req);
|
||||
if (!ym) return res.status(400).json({ error: 'year/month invalides' });
|
||||
|
||||
// Admin can validate on behalf of a cadre (e.g. corrections), otherwise self only.
|
||||
const targetUserId = req.user.role === 'admin' && req.body.user_id ? req.body.user_id : req.user.id;
|
||||
|
||||
const { rows: userRows } = await db.query('SELECT id, company_id FROM users WHERE id = $1', [targetUserId]);
|
||||
const target = userRows[0];
|
||||
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||
|
||||
const { rows: cv } = await db.query(
|
||||
'SELECT admin_validated FROM company_month_validations WHERE company_id = $1 AND year = $2 AND month = $3',
|
||||
[target.company_id, ym.year, ym.month]
|
||||
);
|
||||
if (cv[0]?.admin_validated) {
|
||||
return res.status(423).json({ error: 'La société a déjà été validée par l’administrateur pour ce mois' });
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO month_locks (user_id, year, month, cadre_validated, cadre_validated_at)
|
||||
VALUES ($1, $2, $3, true, now())
|
||||
ON CONFLICT (user_id, year, month)
|
||||
DO UPDATE SET cadre_validated = true, cadre_validated_at = now()`,
|
||||
[target.id, ym.year, ym.month]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Admin reopens a cadre's month (allows corrections again).
|
||||
router.post('/cadre/reopen', requireAdmin, async (req, res) => {
|
||||
const ym = parseYearMonth(req);
|
||||
const { user_id } = req.body || {};
|
||||
if (!ym || !user_id) return res.status(400).json({ error: 'Paramètres invalides' });
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO month_locks (user_id, year, month, cadre_validated, cadre_validated_at)
|
||||
VALUES ($1, $2, $3, false, NULL)
|
||||
ON CONFLICT (user_id, year, month)
|
||||
DO UPDATE SET cadre_validated = false, cadre_validated_at = NULL`,
|
||||
[user_id, ym.year, ym.month]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Admin: status of every cadre in a company for a given month.
|
||||
router.get('/company/:companyId', requireAdmin, async (req, res) => {
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
const companyId = req.params.companyId;
|
||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
||||
|
||||
const { rows: cadres } = await db.query(
|
||||
`SELECT u.id, u.full_name, u.email,
|
||||
ml.cadre_validated, ml.cadre_validated_at
|
||||
FROM users u
|
||||
LEFT JOIN month_locks ml ON ml.user_id = u.id AND ml.year = $2 AND ml.month = $3
|
||||
WHERE u.company_id = $1 AND u.role = 'cadre' AND u.active = true
|
||||
ORDER BY u.full_name`,
|
||||
[companyId, year, month]
|
||||
);
|
||||
|
||||
const { rows: cv } = await db.query(
|
||||
`SELECT admin_validated, admin_validated_at, admin_validated_by
|
||||
FROM company_month_validations WHERE company_id = $1 AND year = $2 AND month = $3`,
|
||||
[companyId, year, month]
|
||||
);
|
||||
|
||||
res.json({
|
||||
cadres: cadres.map((c) => ({
|
||||
id: c.id,
|
||||
fullName: c.full_name,
|
||||
email: c.email,
|
||||
cadreValidated: !!c.cadre_validated,
|
||||
cadreValidatedAt: c.cadre_validated_at,
|
||||
})),
|
||||
allCadresValidated: cadres.length > 0 && cadres.every((c) => c.cadre_validated),
|
||||
companyValidated: !!cv[0]?.admin_validated,
|
||||
companyValidatedAt: cv[0]?.admin_validated_at || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Admin validates the whole company for the month (requires every cadre validated).
|
||||
router.post('/company/:companyId', requireAdmin, async (req, res) => {
|
||||
const ym = parseYearMonth(req);
|
||||
const companyId = req.params.companyId;
|
||||
if (!ym) return res.status(400).json({ error: 'year/month invalides' });
|
||||
|
||||
const { rows: cadres } = await db.query(
|
||||
`SELECT u.id, u.full_name, ml.cadre_validated
|
||||
FROM users u
|
||||
LEFT JOIN month_locks ml ON ml.user_id = u.id AND ml.year = $2 AND ml.month = $3
|
||||
WHERE u.company_id = $1 AND u.role = 'cadre' AND u.active = true`,
|
||||
[companyId, ym.year, ym.month]
|
||||
);
|
||||
|
||||
const pending = cadres.filter((c) => !c.cadre_validated);
|
||||
if (pending.length > 0) {
|
||||
return res.status(409).json({
|
||||
error: 'Tous les cadres doivent valider leur mois avant validation de la société',
|
||||
pending: pending.map((c) => c.full_name),
|
||||
});
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO company_month_validations (company_id, year, month, admin_validated, admin_validated_at, admin_validated_by)
|
||||
VALUES ($1, $2, $3, true, now(), $4)
|
||||
ON CONFLICT (company_id, year, month)
|
||||
DO UPDATE SET admin_validated = true, admin_validated_at = now(), admin_validated_by = $4`,
|
||||
[companyId, ym.year, ym.month, req.user.id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Admin reopens a company's month validation.
|
||||
router.post('/company/:companyId/reopen', requireAdmin, async (req, res) => {
|
||||
const ym = parseYearMonth(req);
|
||||
const companyId = req.params.companyId;
|
||||
if (!ym) return res.status(400).json({ error: 'year/month invalides' });
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO company_month_validations (company_id, year, month, admin_validated, admin_validated_at, admin_validated_by)
|
||||
VALUES ($1, $2, $3, false, NULL, NULL)
|
||||
ON CONFLICT (company_id, year, month)
|
||||
DO UPDATE SET admin_validated = false, admin_validated_at = NULL, admin_validated_by = NULL`,
|
||||
[companyId, ym.year, ym.month]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,84 @@
|
||||
const ExcelJS = require('exceljs');
|
||||
const { STATUS_CODES, STATUS_COLORS, daysInMonth } = require('./status');
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
|
||||
];
|
||||
|
||||
/**
|
||||
* cadres: [{ id, fullName, entries: Map('YYYY-MM-DD:AM'|'PM' -> status) }]
|
||||
*/
|
||||
async function buildCompanyWorkbook({ companyName, year, month, cadres }) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Presencia';
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet('Synthèse', {
|
||||
views: [{ state: 'frozen', xSplit: 1, ySplit: 3 }],
|
||||
});
|
||||
|
||||
const nbDays = daysInMonth(year, month);
|
||||
const titleRow = sheet.addRow([`Présences — ${companyName} — ${MONTH_NAMES[month - 1]} ${year}`]);
|
||||
sheet.mergeCells(1, 1, 1, 1 + nbDays * 2);
|
||||
titleRow.font = { bold: true, size: 14 };
|
||||
titleRow.height = 22;
|
||||
|
||||
const dayHeaderRow = sheet.getRow(2);
|
||||
dayHeaderRow.getCell(1).value = 'Cadre';
|
||||
for (let d = 1; d <= nbDays; d++) {
|
||||
const col = 2 + (d - 1) * 2;
|
||||
dayHeaderRow.getCell(col).value = d;
|
||||
sheet.mergeCells(2, col, 2, col + 1);
|
||||
dayHeaderRow.getCell(col).alignment = { horizontal: 'center' };
|
||||
dayHeaderRow.getCell(col).font = { bold: true };
|
||||
}
|
||||
|
||||
const periodHeaderRow = sheet.getRow(3);
|
||||
periodHeaderRow.getCell(1).value = '';
|
||||
for (let d = 1; d <= nbDays; d++) {
|
||||
const col = 2 + (d - 1) * 2;
|
||||
periodHeaderRow.getCell(col).value = 'AM';
|
||||
periodHeaderRow.getCell(col + 1).value = 'PM';
|
||||
periodHeaderRow.getCell(col).font = { italic: true, size: 9 };
|
||||
periodHeaderRow.getCell(col + 1).font = { italic: true, size: 9 };
|
||||
periodHeaderRow.getCell(col).alignment = { horizontal: 'center' };
|
||||
periodHeaderRow.getCell(col + 1).alignment = { horizontal: 'center' };
|
||||
}
|
||||
|
||||
sheet.getColumn(1).width = 26;
|
||||
for (let d = 1; d <= nbDays; d++) {
|
||||
sheet.getColumn(2 + (d - 1) * 2).width = 5;
|
||||
sheet.getColumn(3 + (d - 1) * 2).width = 5;
|
||||
}
|
||||
|
||||
for (const cadre of cadres) {
|
||||
const row = sheet.addRow([cadre.fullName]);
|
||||
for (let d = 1; d <= nbDays; d++) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const col = 2 + (d - 1) * 2;
|
||||
['AM', 'PM'].forEach((period, idx) => {
|
||||
const status = cadre.entries.get(`${dateStr}:${period}`);
|
||||
const cell = row.getCell(col + idx);
|
||||
cell.alignment = { horizontal: 'center' };
|
||||
if (status) {
|
||||
cell.value = STATUS_CODES[status];
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: STATUS_COLORS[status] },
|
||||
};
|
||||
cell.font = { color: { argb: 'FFFFFFFF' }, bold: true, size: 9 };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const legendRowIdx = sheet.rowCount + 2;
|
||||
sheet.getCell(legendRowIdx, 1).value = 'Légende : P = Présent · A = Absent · CP = Congé · RTT = RTT';
|
||||
sheet.getCell(legendRowIdx, 1).font = { italic: true, size: 9 };
|
||||
|
||||
return workbook;
|
||||
}
|
||||
|
||||
module.exports = { buildCompanyWorkbook, MONTH_NAMES };
|
||||
@@ -0,0 +1,94 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
const { STATUS_LABELS, daysInMonth } = require('./status');
|
||||
const { MONTH_NAMES } = require('./excel');
|
||||
|
||||
const STATUS_HEX = {
|
||||
present: '#2E7D32',
|
||||
absent: '#C62828',
|
||||
conge: '#EF6C00',
|
||||
rtt: '#1565C0',
|
||||
};
|
||||
|
||||
/**
|
||||
* cadres: [{ id, fullName, entries: Map('YYYY-MM-DD:AM'|'PM' -> status) }]
|
||||
* Returns a PDFDocument stream (caller pipes it to res).
|
||||
*/
|
||||
function buildCompanyPdf({ companyName, year, month, cadres }) {
|
||||
const doc = new PDFDocument({ margin: 40, size: 'A4' });
|
||||
const nbDays = daysInMonth(year, month);
|
||||
|
||||
doc.fontSize(18).fillColor('#111').text('Presencia — Récapitulatif de présences', { align: 'left' });
|
||||
doc.moveDown(0.2);
|
||||
doc.fontSize(12).fillColor('#444').text(`${companyName} — ${MONTH_NAMES[month - 1]} ${year}`);
|
||||
doc.moveDown(1);
|
||||
|
||||
cadres.forEach((cadre, idx) => {
|
||||
if (idx > 0) doc.addPage();
|
||||
else doc.moveDown(0.5);
|
||||
|
||||
doc.fontSize(14).fillColor('#111').text(cadre.fullName, { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
const counts = { present: 0, absent: 0, conge: 0, rtt: 0 };
|
||||
|
||||
const startX = doc.x;
|
||||
let y = doc.y;
|
||||
const rowHeight = 16;
|
||||
const colDay = 30;
|
||||
const colAM = 90;
|
||||
const colPM = 90;
|
||||
|
||||
doc.fontSize(9).fillColor('#fff');
|
||||
doc.rect(startX, y, colDay + colAM + colPM, rowHeight).fill('#333');
|
||||
doc.fillColor('#fff').text('Jour', startX + 4, y + 4, { width: colDay - 8 });
|
||||
doc.text('Matin (AM)', startX + colDay + 4, y + 4, { width: colAM - 8 });
|
||||
doc.text('Après-midi (PM)', startX + colDay + colAM + 4, y + 4, { width: colPM - 8 });
|
||||
y += rowHeight;
|
||||
|
||||
for (let d = 1; d <= nbDays; d++) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const am = cadre.entries.get(`${dateStr}:AM`);
|
||||
const pm = cadre.entries.get(`${dateStr}:PM`);
|
||||
if (am) counts[am]++;
|
||||
if (pm) counts[pm]++;
|
||||
|
||||
if (y > 760) {
|
||||
doc.addPage();
|
||||
y = 40;
|
||||
}
|
||||
|
||||
doc.rect(startX, y, colDay + colAM + colPM, rowHeight).strokeColor('#ddd').stroke();
|
||||
doc.fillColor('#111').fontSize(9).text(String(d), startX + 4, y + 4, { width: colDay - 8 });
|
||||
|
||||
doc.fillColor(am ? STATUS_HEX[am] : '#999').text(
|
||||
am ? STATUS_LABELS[am] : '—',
|
||||
startX + colDay + 4,
|
||||
y + 4,
|
||||
{ width: colAM - 8 }
|
||||
);
|
||||
doc.fillColor(pm ? STATUS_HEX[pm] : '#999').text(
|
||||
pm ? STATUS_LABELS[pm] : '—',
|
||||
startX + colDay + colAM + 4,
|
||||
y + 4,
|
||||
{ width: colPM - 8 }
|
||||
);
|
||||
y += rowHeight;
|
||||
}
|
||||
|
||||
y += 10;
|
||||
if (y > 740) {
|
||||
doc.addPage();
|
||||
y = 40;
|
||||
}
|
||||
doc.fontSize(10).fillColor('#111').text(
|
||||
`Total demi-journées — Présent: ${counts.present} · Absent: ${counts.absent} · Congé: ${counts.conge} · RTT: ${counts.rtt}`,
|
||||
startX,
|
||||
y
|
||||
);
|
||||
});
|
||||
|
||||
doc.end();
|
||||
return doc;
|
||||
}
|
||||
|
||||
module.exports = { buildCompanyPdf };
|
||||
@@ -0,0 +1,26 @@
|
||||
const STATUS_LABELS = {
|
||||
present: 'Présent',
|
||||
absent: 'Absent',
|
||||
conge: 'Congé',
|
||||
rtt: 'RTT',
|
||||
};
|
||||
|
||||
const STATUS_CODES = {
|
||||
present: 'P',
|
||||
absent: 'A',
|
||||
conge: 'CP',
|
||||
rtt: 'RTT',
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
present: 'FF2E7D32',
|
||||
absent: 'FFC62828',
|
||||
conge: 'FFEF6C00',
|
||||
rtt: 'FF1565C0',
|
||||
};
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
module.exports = { STATUS_LABELS, STATUS_CODES, STATUS_COLORS, daysInMonth };
|
||||
@@ -0,0 +1,49 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-presencia}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-presencia}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-presencia}
|
||||
volumes:
|
||||
- presencia_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_HOST_PORT:-6543}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-presencia}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: 4790
|
||||
PGHOST: db
|
||||
PGPORT: 5432
|
||||
PGUSER: ${POSTGRES_USER:-presencia}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:-presencia}
|
||||
PGDATABASE: ${POSTGRES_DB:-presencia}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-to-a-long-random-string}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@presencia.local}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!}
|
||||
ADMIN_NAME: ${ADMIN_NAME:-Administrateur}
|
||||
ports:
|
||||
- "${BACKEND_HOST_PORT:-4790}:4790"
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_HOST_PORT:-8781}:80"
|
||||
|
||||
volumes:
|
||||
presencia_pgdata:
|
||||
@@ -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