Files
Presencia/backend/src/routes/validations.js
T
Claude 626b46d9d6 Implémenter le design Presencia (projet Claude Design)
Reprend la maquette Presencia.dc.html sur l'application réelle : thème
clair chaud à accent vert forêt, navigation latérale groupée par rôle et
tableau de bord, en conservant l'intégralité du fonctionnel existant.

Frontend
- Écran de connexion en deux panneaux (argumentaire + formulaire).
- Tableau de bord par rôle : compteurs du mois et liste « à faire
  maintenant » (sociétés prêtes à valider, cadres en retard, saisie à
  compléter), plus l'état du mois par société côté admin.
- Planning : saisie rapide au pinceau, trois affichages (grille, compact,
  liste), sélecteur avec raccourcis clavier (1–4, ⌫, ↵ pour appliquer à
  la journée), barre d'état de verrouillage, « Tout effacer ».
- Nouvelle vue Historique pour les cadres (totaux par mois et statut).
- Validation & export : barre de progression et compteurs de
  demi-journées par cadre.
- Mobile : liste verticale, navigation en barre basse, cibles 44 px.

Backend
- GET /api/validations/overview — état de toutes les sociétés d'un mois.
- GET /api/attendance/history — totaux par mois et par statut.
- DELETE /api/attendance/month — effacement d'un mois (mêmes verrous).
- Compteur de demi-journées saisies par cadre dans /validations/company.

Polices et icônes servies localement (Inter, sous-ensemble de 18 icônes
Phosphor) au lieu de Google Fonts et unpkg : l'application ne fait plus
aucune requête externe au chargement.

Vérifié de bout en bout via la vraie configuration nginx : 62 contrôles
Playwright (desktop et mobile), tous passants, sur base réinitialisée et
rejoués deux fois pour garantir le déterminisme.
2026-08-03 18:00:34 +00:00

189 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 start = `${year}-${String(month).padStart(2, '0')}-01`;
const { rows: cadres } = await db.query(
`SELECT u.id, u.full_name, u.email,
ml.cadre_validated, ml.cadre_validated_at,
COUNT(ae.id) AS filled
FROM users u
LEFT JOIN month_locks ml ON ml.user_id = u.id AND ml.year = $2 AND ml.month = $3
LEFT JOIN attendance_entries ae ON ae.user_id = u.id
AND ae.entry_date >= $4::date AND ae.entry_date < ($4::date + INTERVAL '1 month')
WHERE u.company_id = $1 AND u.role = 'cadre' AND u.active = true
GROUP BY u.id, ml.cadre_validated, ml.cadre_validated_at
ORDER BY u.full_name`,
[companyId, year, month, start]
);
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,
filled: parseInt(c.filled, 10),
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: one-shot overview of every company for a given month (dashboard).
router.get('/overview', requireAdmin, 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 { rows } = await db.query(
`SELECT c.id, c.name,
COUNT(u.id) FILTER (WHERE u.id IS NOT NULL) AS active_cadres,
COUNT(u.id) FILTER (WHERE ml.cadre_validated) AS validated_cadres,
BOOL_OR(cmv.admin_validated) AS company_validated
FROM companies c
LEFT JOIN users u ON u.company_id = c.id AND u.role = 'cadre' AND u.active = true
LEFT JOIN month_locks ml ON ml.user_id = u.id AND ml.year = $1 AND ml.month = $2
LEFT JOIN company_month_validations cmv ON cmv.company_id = c.id AND cmv.year = $1 AND cmv.month = $2
GROUP BY c.id
ORDER BY c.name`,
[year, month]
);
const companies = rows.map((r) => ({
id: r.id,
name: r.name,
activeCadres: parseInt(r.active_cadres, 10),
validatedCadres: parseInt(r.validated_cadres, 10),
companyValidated: !!r.company_validated,
}));
res.json({
companies,
totalActiveCadres: companies.reduce((n, c) => n + c.activeCadres, 0),
lateCadres: companies.reduce((n, c) => n + (c.companyValidated ? 0 : c.activeCadres - c.validatedCadres), 0),
pendingCompanies: companies.filter((c) => !c.companyValidated).length,
});
});
// 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;