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,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 };
|
||||
Reference in new issue
Block a user