mirror of
https://github.com/R0m1k3/Presencia.git
synced 2026-10-22 22:58:03 +02:00
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.
This commit is contained in:
14 files changed
+2033
-1234
No files matched your search
@@ -117,6 +117,83 @@ router.put('/', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Per-month aggregates for the history view: half-day counts by status plus
|
||||
// both validation flags, most recent month first.
|
||||
router.get('/history', async (req, res) => {
|
||||
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 { rows } = await db.query(
|
||||
`WITH months AS (
|
||||
SELECT EXTRACT(YEAR FROM entry_date)::int AS year,
|
||||
EXTRACT(MONTH FROM entry_date)::int AS month,
|
||||
COUNT(*) FILTER (WHERE status = 'present') AS present,
|
||||
COUNT(*) FILTER (WHERE status = 'absent') AS absent,
|
||||
COUNT(*) FILTER (WHERE status = 'conge') AS conge,
|
||||
COUNT(*) FILTER (WHERE status = 'rtt') AS rtt
|
||||
FROM attendance_entries
|
||||
WHERE user_id = $1
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
SELECT m.*, COALESCE(ml.cadre_validated, false) AS cadre_validated,
|
||||
COALESCE(cmv.admin_validated, false) AS company_validated
|
||||
FROM months m
|
||||
LEFT JOIN month_locks ml ON ml.user_id = $1 AND ml.year = m.year AND ml.month = m.month
|
||||
LEFT JOIN users u ON u.id = $1
|
||||
LEFT JOIN company_month_validations cmv
|
||||
ON cmv.company_id = u.company_id AND cmv.year = m.year AND cmv.month = m.month
|
||||
ORDER BY m.year DESC, m.month DESC
|
||||
LIMIT 24`,
|
||||
[target.id]
|
||||
);
|
||||
|
||||
res.json(rows.map((r) => ({
|
||||
year: r.year,
|
||||
month: r.month,
|
||||
present: parseInt(r.present, 10),
|
||||
absent: parseInt(r.absent, 10),
|
||||
conge: parseInt(r.conge, 10),
|
||||
rtt: parseInt(r.rtt, 10),
|
||||
cadreValidated: r.cadre_validated,
|
||||
companyValidated: r.company_validated,
|
||||
})));
|
||||
});
|
||||
|
||||
// Clear every entry of a month (the « Tout effacer » action). Same lock rules
|
||||
// as writes.
|
||||
router.delete('/month', async (req, res) => {
|
||||
const { year, month, user_id: requestedUserId } = req.body || {};
|
||||
const y = parseInt(year, 10);
|
||||
const m = parseInt(month, 10);
|
||||
if (!y || !m || m < 1 || m > 12) 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 lock = await getMonthLock(target.id, y, m);
|
||||
const companyValidation = await getCompanyValidation(target.company_id, y, m);
|
||||
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.' });
|
||||
}
|
||||
|
||||
const start = `${y}-${String(m).padStart(2, '0')}-01`;
|
||||
await db.query(
|
||||
`DELETE FROM attendance_entries
|
||||
WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`,
|
||||
[target.id, start]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Bulk upsert (e.g. « fill all empty weekdays with présent »). All entries
|
||||
// must pass the same lock checks as single writes; months are checked once
|
||||
// per distinct month present in the payload.
|
||||
|
||||
@@ -65,14 +65,19 @@ router.get('/company/:companyId', requireAdmin, async (req, res) => {
|
||||
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
|
||||
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]
|
||||
[companyId, year, month, start]
|
||||
);
|
||||
|
||||
const { rows: cv } = await db.query(
|
||||
@@ -86,6 +91,7 @@ router.get('/company/:companyId', requireAdmin, async (req, res) => {
|
||||
id: c.id,
|
||||
fullName: c.full_name,
|
||||
email: c.email,
|
||||
filled: parseInt(c.filled, 10),
|
||||
cadreValidated: !!c.cadre_validated,
|
||||
cadreValidatedAt: c.cadre_validated_at,
|
||||
})),
|
||||
@@ -95,6 +101,42 @@ router.get('/company/:companyId', requireAdmin, async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
Reference in new issue
Block a user