mirror of
https://github.com/R0m1k3/Presencia.git
synced 2026-10-11 17:27:42 +02:00
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.
189 lines
7.4 KiB
JavaScript
189 lines
7.4 KiB
JavaScript
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;
|