diff --git a/README.md b/README.md index 713cbfb..94505e7 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,15 @@ Application de gestion des présences par demi-journée, multi-sociétés, avec - **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. +- **Tableau de bord** adapté au rôle : compteurs du mois et raccourcis « à faire maintenant » (sociétés prêtes à valider, cadres en retard, saisie à compléter). +- **Planning mensuel** : clic sur une demi-journée (matin/après-midi) pour choisir Présent, Non-présent, Congé ou RTT. Trois affichages au choix (grille, compact, liste), **saisie rapide au pinceau** (on active un statut puis on peint les cases), **raccourcis clavier** dans le sélecteur (1–4 pour les statuts, ⌫ pour effacer, ↵ pour appliquer à la journée entière), et deux actions groupées : « Remplir les jours ouvrés » et « Tout effacer ». - **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. +- **Historique** : chaque cadre retrouve ses mois précédents avec les totaux par statut. - **Export** PDF et Excel par société et par mois (détail par cadre, jour et demi-journée). +- **Interface mobile** : sous 900 px, le calendrier devient une liste verticale, la navigation passe en barre basse et les cibles tactiles font 44 px. +- **Aucune dépendance externe au chargement** : la police (Inter) et les icônes (Phosphor) sont servies par l'application elle-même — aucune requête vers un CDN ou vers Google Fonts. - **Docker** : Postgres + API Node/Express + frontend Nginx, sur des ports non standards. ## Démarrage @@ -46,17 +50,32 @@ Si vous changez les identifiants admin dans `docker-compose.yml` *après* un pre ### 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. +- Renseigner sa présence par demi-journée sur le mois en cours (ou les mois précédents/suivants). Le plus rapide : cliquer **« Remplir les jours ouvrés »** puis ne corriger que les exceptions (congés, RTT, absences). +- Pour saisir plusieurs cases d'affilée, activer un statut dans **Saisie rapide** : chaque clic applique directement ce statut, sans passer par le sélecteur. +- Dans le sélecteur d'une demi-journée : touches **1** à **4** pour les statuts, **⌫** pour effacer, **↵** pour appliquer à la journée entière. +- 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. +- **Historique** : retrouver les mois précédents et leurs totaux, et les rouvrir en lecture. ## 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 + public/css/styles.css tokens et composants du design system (Nocturne) + public/css/presencia.css couche applicative du design (calendrier, navigation, statuts) + public/css/app.css glue : mises en page que la maquette exprimait en styles inline + public/css/inter.css police Inter, servie localement + public/css/phosphor.css sous-ensemble des 18 icônes utilisées, servi localement docker-compose.yml ``` +`styles.css` et `presencia.css` proviennent du projet Claude Design et sont à +remplacer tels quels lors d'une nouvelle exportation du design ; les +adaptations propres à l'application vivent dans `app.css` pour que cette +réimportation reste triviale. Seule modification apportée à `styles.css` : +l'`@import` vers Google Fonts a été retiré au profit de `inter.css` servi +localement. + ### Modèle de données (PostgreSQL) - `companies` — sociétés diff --git a/backend/src/routes/attendance.js b/backend/src/routes/attendance.js index e80c209..705970b 100644 --- a/backend/src/routes/attendance.js +++ b/backend/src/routes/attendance.js @@ -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. diff --git a/backend/src/routes/validations.js b/backend/src/routes/validations.js index ba4dc14..604a0e1 100644 --- a/backend/src/routes/validations.js +++ b/backend/src/routes/validations.js @@ -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); diff --git a/frontend/public/css/app.css b/frontend/public/css/app.css new file mode 100644 index 0000000..6fd9e9a --- /dev/null +++ b/frontend/public/css/app.css @@ -0,0 +1,162 @@ +/* Presencia app glue: the layout the design expressed as inline styles on the + .dc mockup, plus the few behaviours a static mockup has no need for + (hidden toggling, toast animation, dialog forms). Loaded last. + Design tokens and component classes live in styles.css / presencia.css. */ + +/* Any element toggled via the `hidden` attribute must stay hidden even when a + class on it sets a display value. */ +[hidden] { display: none !important; } + +body { margin: 0; } + +/* ── brand ── */ +.brand-row { display: flex; align-items: center; gap: 9px; } +.brand-mark { width: 22px; height: 22px; border-radius: 6px; background: var(--color-accent); display: block; flex: none; } +.brand-mark.small { width: 20px; height: 20px; } +.brand-mark.tiny { width: 18px; height: 18px; border-radius: 5px; } +.brand-name { font-size: 18px; font-weight: 500; letter-spacing: -0.01em; } +.brand-name.small { font-size: 16px; } + +/* ── login ── */ +.login-split { min-height: 100vh; display: grid; grid-template-columns: 1.1fr 1fr; background: var(--color-bg); } +.login-pitch { display: flex; flex-direction: column; justify-content: space-between; padding: 44px 48px; border-right: 1px solid var(--color-divider); } +.login-pitch-copy { max-width: 420px; display: flex; flex-direction: column; gap: 14px; } +.login-pitch-copy h1 { font-size: 40px; margin: 0; } +.login-pitch-copy p { font-size: 15px; margin: 0; color: var(--color-neutral-700); } +.login-pitch-feet { display: flex; gap: 26px; font-size: 12px; color: var(--color-neutral-700); flex-wrap: wrap; } +.login-formside { display: grid; place-items: center; padding: 32px; } +.login-form { width: min(360px, 100%); display: flex; flex-direction: column; gap: 16px; } +.login-form h3 { margin: 0 0 4px; } +.login-lede { font-size: 13px; margin: 0; color: var(--color-neutral-700); } +.login-error { font-size: 13px; padding: 9px 11px; border-radius: var(--radius-md); background: #fbeae6; color: #a8412c; } + +@media (max-width: 860px) { + .login-split { grid-template-columns: 1fr; } + .login-pitch { border-right: 0; border-bottom: 1px solid var(--color-divider); padding: 24px 20px; gap: 20px; } + .login-pitch-copy h1 { font-size: 28px; } + .login-pitch-feet { display: none; } + .login-formside { padding: 24px 20px 40px; place-items: start center; } +} + +/* ── sidebar ── */ +.side-brand { display: flex; align-items: center; gap: 9px; padding: 4px 10px 2px; } +.side nav { display: flex; flex-direction: column; gap: 14px; } +.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; padding: 0 4px; } +.side-user { display: flex; align-items: center; gap: 9px; padding: 8px 6px; border-top: 1px solid var(--color-divider); } +.avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--color-accent-100); color: var(--color-accent-700); display: grid; place-items: center; font-size: 12px; flex: none; } +.avatar.neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); font-size: 11px; } +.side-user-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; } +.side-user-name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.side-user-meta { font-size: 11px; color: var(--color-neutral-700); } + +/* ── mobile chrome ── */ +.mobile-head { align-items: center; gap: 10px; margin-bottom: 14px; } +.mobile-title { font-size: 15px; font-weight: 500; margin-right: auto; } +.mobile-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; background: var(--color-surface); border-top: 1px solid var(--color-divider); padding: 6px 8px; justify-content: space-around; } +.mobile-nav .navitem { flex-direction: column; gap: 3px; justify-content: center; text-align: center; padding: 6px 4px; font-size: 10px; } +.mobile-nav .navitem .ph { font-size: 19px; } + +/* ── generic stacks / heads ── */ +.stack-20 { display: flex; flex-direction: column; gap: 20px; } +.stack-14 { display: flex; flex-direction: column; gap: 14px; } +.stack-8 { display: flex; flex-direction: column; gap: 8px; } +.stack-6 { display: flex; flex-direction: column; gap: 6px; } +.lede { font-size: 13px; margin: 0; color: var(--color-neutral-700); } +.view h3 { margin: 0 0 2px; } +.view h6 { margin: 0; } +.page-head { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; } +.page-head > div { margin-right: auto; } +.table-card { padding: 6px 10px 10px; } +.filter-bar { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; } +.filter-bar .field { min-width: 200px; } +.cell-muted { color: var(--color-neutral-700); } +.cell-actions { text-align: right; white-space: nowrap; } +.empty-row td { color: var(--color-neutral-700); font-size: 13px; } + +/* ── dashboard ── */ +.tile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } +.tile-label { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-neutral-700); } +.tile-value { font-size: 26px; font-weight: 500; line-height: 1.15; } +.tile-hint { font-size: 12px; color: var(--color-neutral-700); } +.rowbtn-ico { font-size: 17px; color: var(--color-accent-600); flex: none; } +.rowbtn-text { display: flex; flex-direction: column; line-height: 1.35; min-width: 0; text-align: left; } +.rowbtn-sub { font-size: 12px; color: var(--color-neutral-700); } +.rowbtn-right { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.rowbtn-right .ph-arrow-right { font-size: 14px; opacity: .5; } + +/* ── planning ── */ +.planning-head { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; } +.planning-head-title { margin-right: auto; } +.back-link { margin-bottom: 4px; padding-inline: 0; } +.month-nav { display: flex; align-items: center; gap: 6px; } +.month-label { font-size: 14px; font-weight: 500; min-width: 132px; text-align: center; text-transform: capitalize; } + +.lockbar { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; background: var(--color-surface); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 7%, transparent); flex-wrap: wrap; } +.lockbar-icon { font-size: 17px; color: var(--color-accent-600); } +.lockbar-text { display: flex; flex-direction: column; line-height: 1.35; } +.lockbar-title { font-size: 13px; font-weight: 500; } +.lockbar-detail { font-size: 12px; color: var(--color-neutral-700); } +.lockbar-actions { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; } + +.toolrow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.toolrow-label { font-size: 12px; color: var(--color-neutral-700); margin-right: 2px; } +.toolrow-items { display: flex; gap: 8px; flex-wrap: wrap; } +.toolrow-right { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; } +.brush-hint { font-size: 12px; color: var(--color-neutral-700); } +.view-badge { font-size: 10px; letter-spacing: .08em; opacity: .6; } + +.day-head { display: flex; align-items: baseline; gap: 5px; } +.day-num { font-size: 12px; font-weight: 600; } +.day-wd { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: var(--color-neutral-700); } + +.summary-row { display: flex; gap: 8px; flex-wrap: wrap; } +.summary-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 8px; background: var(--color-surface); font-size: 12px; } +.summary-chip-value { font-weight: 500; } +.summary-chip-label { color: var(--color-neutral-700); } + +/* ── validation ── */ +.validation-card { padding: 14px; gap: 12px; } +.validation-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; } +.validation-head-text { display: flex; flex-direction: column; line-height: 1.4; } +.validation-company { font-size: 17px; font-weight: 500; } +.validation-status { font-size: 12px; color: var(--color-neutral-700); } +.validation-head-actions { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; } +.progress { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--color-text) 10%, transparent); overflow: hidden; } +.progress-bar { display: block; height: 100%; background: var(--color-accent); width: 0; transition: width .2s ease; } + +/* ── popover ── */ +.pop-backdrop { position: fixed; inset: 0; z-index: 80; } +.pop-head { display: flex; align-items: center; gap: 8px; padding: 4px 8px 6px; } +.pop-title { font-size: 12px; font-weight: 500; } +.pop-hint { margin-left: auto; font-size: 10px; color: var(--color-neutral-700); } +.pop-opt-muted { color: var(--color-neutral-700); } +.pop-ico { font-size: 14px; width: 9px; } + +/* ── dialog ── */ +.dialog-backdrop { z-index: 130; } +.dialog h4 { margin: 0; } +.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); } +.dialog .field + .field { margin-top: var(--space-3); } + +/* ── toast ── */ +.toast { + position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); + z-index: 140; padding: 10px 16px; border-radius: 8px; + background: var(--color-surface); box-shadow: var(--shadow-lg); font-size: 13px; + max-width: calc(100vw - 32px); text-align: center; + /* never swallow clicks meant for the UI underneath */ + pointer-events: none; +} +.toast.is-error { background: #fbeae6; color: #a8412c; } + +@media (max-width: 900px) { + .toast { bottom: 76px; } + .filter-bar .field { min-width: 0; flex: 1 1 160px; } + .month-label { min-width: 104px; } + .lockbar-actions { width: 100%; margin-left: 0; } + .lockbar-actions .btn { flex: 1; } + .toolrow-right { width: 100%; margin-left: 0; } + .toolrow-right .btn { flex: 1; } +} + +.nav-group-items { display: flex; flex-direction: column; gap: 2px; } diff --git a/frontend/public/css/inter.css b/frontend/public/css/inter.css new file mode 100644 index 0000000..fb4ebb2 --- /dev/null +++ b/frontend/public/css/inter.css @@ -0,0 +1,75 @@ +/* Inter (SIL Open Font License 1.1) — vendored latin subsets so the app + loads no external font and makes no request to Google. */ + +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../fonts/inter-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(../fonts/inter-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../fonts/inter-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(../fonts/inter-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../fonts/inter-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(../fonts/inter-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(../fonts/inter-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(../fonts/inter-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/frontend/public/css/phosphor.css b/frontend/public/css/phosphor.css new file mode 100644 index 0000000..4e3d91c --- /dev/null +++ b/frontend/public/css/phosphor.css @@ -0,0 +1,90 @@ +/* Phosphor Icons (MIT) — vendored subset: only the glyphs Presencia uses, + so the app renders identically with no external CDN request. */ +@font-face { + font-family: "Phosphor"; + src: + url("../fonts/Phosphor.woff2") format("woff2"); + font-weight: normal; + font-style: normal; + font-display: block; +} + +.ph { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: "Phosphor" !important; + speak: never; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + + /* Enable Ligatures ================ */ + letter-spacing: 0; + -webkit-font-feature-settings: "liga"; + -moz-font-feature-settings: "liga=1"; + -moz-font-feature-settings: "liga"; + -ms-font-feature-settings: "liga" 1; + font-feature-settings: "liga"; + -webkit-font-variant-ligatures: discretionary-ligatures; + font-variant-ligatures: discretionary-ligatures; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ph.ph-arrow-right:before { + content: "\e06c"; +} +.ph.ph-arrows-out-line-horizontal:before { + content: "\e534"; +} +.ph.ph-bell-ringing:before { + content: "\e5e8"; +} +.ph.ph-buildings:before { + content: "\e102"; +} +.ph.ph-calendar-dots:before { + content: "\e7b4"; +} +.ph.ph-caret-left:before { + content: "\e138"; +} +.ph.ph-caret-right:before { + content: "\e13a"; +} +.ph.ph-clock-counter-clockwise:before { + content: "\e1a0"; +} +.ph.ph-file-pdf:before { + content: "\e702"; +} +.ph.ph-file-xls:before { + content: "\eb22"; +} +.ph.ph-lock-simple:before { + content: "\e308"; +} +.ph.ph-pencil-simple-line:before { + content: "\ebc6"; +} +.ph.ph-plus:before { + content: "\e3d4"; +} +.ph.ph-seal-check:before { + content: "\e606"; +} +.ph.ph-sign-out:before { + content: "\e42a"; +} +.ph.ph-squares-four:before { + content: "\e464"; +} +.ph.ph-user-gear:before { + content: "\e4cc"; +} +.ph.ph-users-three:before { + content: "\e68e"; +} diff --git a/frontend/public/css/presencia.css b/frontend/public/css/presencia.css new file mode 100644 index 0000000..4ac3424 --- /dev/null +++ b/frontend/public/css/presencia.css @@ -0,0 +1,134 @@ +/* Presencia — app layer over the Nocturne tokens. Only what inline styles + cannot express: status colors, responsive layout, hover/pseudo states. */ + +/* Light, natural retheme: warm off-white grounds, forest-green accent, no violet. + Overrides the token sheet loaded before this file. */ +:root { + --color-bg: #f4f3ef; + --color-surface: #ffffff; + --color-text: #1f2320; + --color-accent: #3f6b54; + --color-accent-2: #3f6b54; + --color-divider: color-mix(in srgb, #1f2320 12%, transparent); + + --color-accent-100: #eaf1ec; + --color-accent-200: #d4e3d9; + --color-accent-300: #b0cbbb; + --color-accent-400: #7fa993; + --color-accent-500: #5c8a72; + --color-accent-600: #3f6b54; + --color-accent-700: #325742; + --color-accent-800: #274233; + --color-accent-900: #1b2e24; + + --color-accent-2-100: #eaf1ec; + --color-accent-2-200: #d4e3d9; + --color-accent-2-300: #b0cbbb; + --color-accent-2-400: #7fa993; + --color-accent-2-500: #5c8a72; + --color-accent-2-600: #3f6b54; + --color-accent-2-700: #325742; + --color-accent-2-800: #274233; + --color-accent-2-900: #1b2e24; + + --color-neutral-100: #f8f8f6; + --color-neutral-200: #ededea; + --color-neutral-300: #dcdcd7; + --color-neutral-400: #c2c2bc; + --color-neutral-500: #a0a09a; + --color-neutral-600: #7d7d77; + --color-neutral-700: #5c5c57; + --color-neutral-800: #40403c; + --color-neutral-900: #2a2a27; + + --shadow-sm: 0 0 0 1px rgba(31, 35, 32, 0.09); + --shadow-md: 0 1px 2px rgba(31, 35, 32, 0.06), 0 6px 16px rgba(31, 35, 32, 0.08); + --shadow-lg: 0 0 0 1px rgba(31, 35, 32, 0.06), 0 14px 36px rgba(31, 35, 32, 0.14); +} + +.tag-accent, .tag-accent-2 { background: var(--color-accent-100); color: var(--color-accent-700); } +.tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); } +.table th, .text-muted, .card-meta, figcaption, .field > label { color: var(--color-neutral-700); } + +.shell { display: grid; grid-template-columns: 232px minmax(0, 1fr); min-height: 100vh; } +.side { border-right: 1px solid var(--color-divider); padding: 14px 12px 24px; display: flex; flex-direction: column; gap: 18px; position: sticky; top: 0; height: 100vh; } +.side-group-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-neutral-700); padding: 0 10px 6px; } +.navitem { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-neutral-800); font: inherit; font-size: 14px; cursor: pointer; text-align: left; } +.navitem:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); color: var(--color-text); } +.navitem[aria-current="page"] { background: var(--color-accent-100); color: var(--color-accent-700); box-shadow: inset 2px 0 0 var(--color-accent); } +.navitem .ni-badge { margin-left: auto; font-size: 11px; padding: 1px 7px; border-radius: 999px; background: var(--color-accent-600); color: #fff; } +.navitem .ni-ico { width: 18px; text-align: center; opacity: .85; font-size: 15px; } + +.content { padding: 22px 28px 72px; max-width: 1120px; min-width: 0; } +.content .card:has(> .table), .content .card:has(> table.table) { overflow-x: auto; } +.content table.table { min-width: 560px; } + +.cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; } +.cal-head { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--color-neutral-700); padding-bottom: 6px; } +.day { background: var(--color-surface); border-radius: 8px; padding: 6px; display: flex; flex-direction: column; gap: 5px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 7%, transparent); } +.day.is-blank { background: transparent; box-shadow: none; } +.day.is-weekend { background: var(--color-neutral-100); } +.day.is-today { box-shadow: inset 0 0 0 1px var(--color-accent); } +.halves { display: flex; gap: 4px; min-width: 0; } +.half-lbl-long { display: none; } +.pv-list .half-lbl-long { display: inline; } +.pv-list .half-lbl-short { display: none; } +.half { flex: 1 1 0; min-width: 0; overflow: hidden; min-height: 40px; border-radius: 6px; border: 1px dashed color-mix(in srgb, var(--color-text) 14%, transparent); background: transparent; color: var(--color-neutral-600); font: inherit; font-size: 10px; font-weight: 600; letter-spacing: .04em; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 4px; transition: transform .08s ease, filter .12s ease; } +.half:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 1px 3px rgba(31,35,32,.12); } +.half:disabled { cursor: default; } +.half.st-present { border-style: solid; border-color: transparent; background: #e7f0e9; color: #2f6b45; } +.half.st-absent { border-style: solid; border-color: transparent; background: #fbeae6; color: #a8412c; } +.half.st-conge { border-style: solid; border-color: transparent; background: #fbf1e0; color: #8a5c14; } +.half.st-rtt { border-style: solid; border-color: transparent; background: #e6eef3; color: #33607f; } + +/* Compact direction — 1b */ +.pv-dense .day { padding: 4px; gap: 3px; } +.pv-dense .halves { flex-direction: column; gap: 2px; } +.pv-dense .half { min-height: 17px; font-size: 9px; } + +/* List direction — 1c */ +.pv-list .cal-head { display: none; } +.pv-list .cal-grid { display: flex; flex-direction: column; gap: 4px; } +.pv-list .day { flex-direction: row; align-items: center; gap: 12px; padding: 7px 10px; } +.pv-list .day.is-blank { display: none; } +.pv-list .halves { flex: 1; max-width: 440px; } +.pv-list .half { min-height: 44px; font-size: 11px; } + +.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; } +.dot.st-present { background: #4b8a63; } +.dot.st-absent { background: #c0563c; } +.dot.st-conge { background: #b8862f; } +.dot.st-rtt { background: #4a7a9b; } +.dot.st-none { background: var(--color-neutral-400); } + +.pop { position: fixed; z-index: 90; width: 214px; padding: 6px; border-radius: 10px; background: var(--color-surface); box-shadow: var(--shadow-lg); } +.pop-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 8px; border: 0; border-radius: 6px; background: transparent; color: var(--color-text); font: inherit; font-size: 14px; cursor: pointer; text-align: left; } +.pop-opt:hover { background: color-mix(in srgb, var(--color-text) 8%, transparent); } +.pop-opt .kbd { margin-left: auto; font-size: 10px; padding: 1px 5px; border-radius: 4px; color: var(--color-neutral-700); box-shadow: inset 0 0 0 1px var(--color-neutral-300); } + +.btn, .chipbtn { white-space: nowrap; } +.chipbtn { display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 8px; border: 1px solid var(--color-divider); background: transparent; color: var(--color-neutral-800); font: inherit; font-size: 13px; cursor: pointer; } +.chipbtn:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.chipbtn[aria-pressed="true"] { color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); border-color: var(--color-accent); } + +.tile { background: var(--color-surface); border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 7%, transparent); } +.rowbtn { display: flex; align-items: center; gap: 12px; width: 100%; padding: 11px 12px; border: 0; border-radius: 8px; background: var(--color-surface); color: var(--color-text); font: inherit; font-size: 14px; text-align: left; cursor: pointer; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 7%, transparent); } +.rowbtn:hover { background: color-mix(in srgb, var(--color-text) 10%, transparent); } + +.mobile-only { display: none; } + +@media (max-width: 900px) { + .shell { grid-template-columns: 1fr; } + .side { display: none; } + .content { padding: 14px 14px 96px; } + .mobile-only { display: flex; } + .cal-head { display: none; } + .cal-grid { display: flex; flex-direction: column; gap: 4px; } + .day { flex-direction: row; align-items: center; gap: 12px; padding: 7px 10px; } + .day.is-blank { display: none; } + .halves { flex: 1; } + .half-lbl-long { display: inline; } + .half-lbl-short { display: none; } + .half { min-height: 44px; font-size: 11px; } + .pv-dense .halves, .pv-dense .half { flex-direction: row; min-height: 44px; font-size: 11px; } +} diff --git a/frontend/public/css/style.css b/frontend/public/css/style.css deleted file mode 100644 index 5f814a3..0000000 --- a/frontend/public/css/style.css +++ /dev/null @@ -1,419 +0,0 @@ -: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; gap: 10px; flex-wrap: wrap; } - -/* Highlight of the current day */ -.day-cell.today { border-color: var(--color-primary); box-shadow: inset 0 0 0 1px var(--color-primary); } -.day-cell.today .day-number { color: var(--color-primary); } - -/* ---------- Monthly summary tiles ---------- */ -.month-summary { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin-bottom: 16px; -} -.month-summary:empty { display: none; } -.summary-tile { - display: flex; - align-items: baseline; - gap: 7px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 8px; - padding: 8px 14px; -} -.summary-count { font-size: 18px; font-weight: 700; } -.summary-label { font-size: 12px; color: var(--color-muted); font-weight: 600; } -.summary-present .summary-count { color: var(--color-present); } -.summary-absent .summary-count { color: var(--color-absent); } -.summary-conge .summary-count { color: var(--color-conge); } -.summary-rtt .summary-count { color: var(--color-rtt); } - -/* ---------- 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; - /* Notifications must never block clicks on the UI underneath them. */ - pointer-events: none; -} -.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; } - -.day-weekday-mobile { display: none; } - -.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } - -@media (max-width: 720px) { - .main { padding: 16px 12px 50px; } - .selector-card { flex-direction: column; align-items: stretch; } - .topbar { padding: 10px 14px; } - .topbar-user { gap: 8px; } -} - -/* Below this width the 7-column grid becomes unusable by touch, so the - calendar switches to a single-column list: one full-width row per day. */ -@media (max-width: 640px) { - .calendar-header { flex-direction: column; } - .calendar-weekdays { display: none; } - - .calendar-grid { - display: flex; - flex-direction: column; - gap: 6px; - } - .day-cell { - flex-direction: row; - align-items: center; - min-height: unset; - padding: 10px; - gap: 10px; - } - .day-cell.empty { display: none; } - .day-number { - flex: 0 0 auto; - width: 46px; - display: flex; - flex-direction: column; - align-items: center; - line-height: 1.2; - } - .day-weekday-mobile { - display: block; - font-size: 10px; - font-weight: 700; - color: var(--color-muted); - text-transform: uppercase; - } - .half-day-row { gap: 8px; } - .half-day { min-height: 44px; font-size: 11px; } - - .calendar-actions { justify-content: stretch; } - .calendar-actions .btn { width: 100%; } - - .legend { gap: 10px 14px; font-size: 12px; } - - .popover { max-width: calc(100vw - 16px); } - - .modal { padding: 20px; } -} diff --git a/frontend/public/css/styles.css b/frontend/public/css/styles.css new file mode 100644 index 0000000..8dbe634 --- /dev/null +++ b/frontend/public/css/styles.css @@ -0,0 +1,296 @@ +/* Nocturne — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */ +/* Inter is loaded from css/inter.css (vendored locally) instead of the + Google Fonts import this file ships with: the app must not call out to a + third party at page load. */ + +:root { + --color-bg: #161826; + --color-surface: #232532; + --color-text: #e9e9ed; + --color-accent: #9184d9; /* review round 7 (Barron): the accent moves to + the product's blurple — OKLCH hue 289.2, the hue of the app's own Pro + accent (#9a8fe0, --om-accent-pro) — at the same lightness the steel + accent held (L 0.660, so the documented ratios survive) and chroma + in the product blurple's own range (its token sits at C 0.118; the + accent takes 0.125, from the old steel's 0.030): + the accent now reads as an accent rather than another neutral. */ + --color-accent-2: #a7a1db; /* the machine-derived stand-in follows: same + hue, same L 0.734, chroma at the same 2:3 proportion it held */ + --color-divider: color-mix(in srgb, #e9e9ed 16%, transparent); + + /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the + same step of any role matches the others in visual value. */ + --color-neutral-100: #f3f5fe; + --color-neutral-200: #e4e7f5; + --color-neutral-300: #cfd3e5; + --color-neutral-400: #b2b6ca; + --color-neutral-500: #9397ab; + --color-neutral-600: #75798c; + --color-neutral-700: #595d6c; + --color-neutral-800: #3f424d; + --color-neutral-900: #292b31; + + --color-accent-100: #f5f4ff; + --color-accent-200: #e7e5fe; + --color-accent-300: #d2cefd; + --color-accent-400: #b5abfc; + --color-accent-500: #968ae0; + --color-accent-600: #796cbf; + --color-accent-700: #5d5294; + --color-accent-800: #423a6a; + --color-accent-900: #2b2741; + + --color-accent-2-100: #f5f4ff; + --color-accent-2-200: #e7e5fe; + --color-accent-2-300: #d2cefd; + --color-accent-2-400: #b5afe8; + --color-accent-2-500: #9690c9; + --color-accent-2-600: #7972a9; + --color-accent-2-700: #5c5783; + --color-accent-2-800: #423e5d; + --color-accent-2-900: #2b293a; + + /* Deck section-divider ground — review round 3: saturation as presence. + Dividers take a saturated deep indigo (hue 277, the neutral ramp's own + family; C 0.094 — written when the accent held C 0.030, before the + round-7 blurple raised it past the ground's) with a lighter bloom and + a ghost tint of the same hue, while the slide grounds stay + desaturated. Deck-scale fills only — not interface colors. */ + --color-section: #262a60; + --color-section-glow: #353b80; + --color-section-ghost: #4c5397; + + --font-heading: "Inter", system-ui, sans-serif; + --font-heading-weight: 500; + --font-body: "Inter", system-ui, sans-serif; + + --space-1: 2.8px; + --space-2: 5.6px; + --space-3: 8.4px; + --space-4: 11.2px; + --space-6: 16.8px; + --space-8: 22.4px; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 14px; + + /* Elevation — derived from the ground: soft ink-tinted shadows on a + light theme, a hairline edge + ambient darkness on a dark one. */ + --shadow-sm: 0 0 0 1px #3f424d; + --shadow-md: 0 0 0 1px #595d6c, 0 6px 18px rgba(0,0,0,0.55); + --shadow-lg: 0 0 0 1px #9397ab, 0 16px 40px rgba(0,0,0,0.65); +} + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); +} +h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); } + +.lighten{mix-blend-mode:lighten;background-color:transparent} + +/* ══════════════════════════════════════════════════════════════════════════ + Components — built with the tokens above. Plain CSS + on plain HTML: no JavaScript, no build step. Each class is documented in + readme.md and demonstrated in foundations/ and components/. + ══════════════════════════════════════════════════════════════════════ */ + +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; } +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2); +} +h1 { font-size: 42px; } +h2 { font-size: 32px; } +h3 { font-size: 25px; } +h4 { font-size: 20px; } +h5 { font-size: 16px; } +h6 { font-size: 13px; } +h6 { letter-spacing: 0.08em; text-transform: uppercase; } +p { margin: 0 0 var(--space-3); } +a { color: var(--color-accent); text-underline-offset: 3px; } +img { display: block; max-width: 100%; } +figure { margin: 0; } +figcaption { + font-size: 11px; margin-top: var(--space-1); + color: color-mix(in srgb, var(--color-text) 55%, transparent); +} +.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); } +:focus { outline: none; } +:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); } + +/* — rules — */ +/* Rules fade to transparent at both ends instead of stopping cleanly — a + Nocturne signature. The ramp is 48px an end (one deck baseline unit), + painted from the same divider token. Freestanding rules fade; box + outlines, in-control separators, and short accent marks stay solid. */ +.hr { + height: 1px; border: 0; margin: var(--space-4) 0; + background: linear-gradient(to right, + transparent, var(--color-divider) 48px, + var(--color-divider) calc(100% - 48px), transparent); +} + +/* — buttons — */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + cursor: pointer; text-decoration: none; + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px — + the pair sits side by side in sign-up rows */ + background: transparent; border: 1px solid transparent; + padding: var(--space-2) calc(var(--space-3) * 1.2); + border-radius: var(--radius-md); +} +.btn svg { display: block; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn-primary { color: var(--color-accent); border-color: var(--color-accent); } +.btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 12%, transparent); } +.btn-primary:active { background: color-mix(in srgb, var(--color-accent) 22%, transparent); } +.btn-secondary { border-color: var(--color-divider); } +.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); } +.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); } +.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); } +.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); } +.btn-icon { width: 36px; height: 36px; padding: 0; } +.btn-block { width: 100%; margin-top: var(--space-2); } + +/* — forms — */ +.field > label { + display: block; font-size: 12px; margin-bottom: 5px; + color: color-mix(in srgb, var(--color-text) 70%, transparent); +} +.input { + width: 100%; min-height: 36px; padding: 6px 10px; font: inherit; + font-size: 14px; color: var(--color-text); caret-color: var(--color-accent); + background: var(--color-surface); + border: 1px solid var(--color-divider); border-radius: var(--radius-md); +} +.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); } +.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; } +textarea.input { min-height: 90px; resize: vertical; } +.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; } +.radio input, .seg-opt input { + position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; +} +.radio .dot { + width: 16px; height: 16px; flex: none; border-radius: 50%; + border: 1.5px solid var(--color-divider); +} +.radio:hover .dot { border-color: var(--color-accent); } +.radio input:checked + .dot { + border-color: var(--color-accent); background: var(--color-accent); + box-shadow: inset 0 0 0 4px var(--color-bg); +} +.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.seg { + display: inline-flex; overflow: hidden; + border: 1px solid var(--color-divider); border-radius: var(--radius-md); +} +.seg-opt { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 12px; font-size: 13px; cursor: pointer; +} +.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); } +.seg-opt:has(input:checked) { color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); } +.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; } + +/* — cards — */ +.card { + display: flex; flex-direction: column; gap: var(--space-2); + padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface); +} +.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); } +.card-title { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 17px; line-height: 1.2; +} +.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; } +.card-meta { + display: flex; align-items: center; gap: 6px; font-size: 11px; + color: color-mix(in srgb, var(--color-text) 50%, transparent); +} +.elev-sm { box-shadow: var(--shadow-sm); } +.elev-md { box-shadow: var(--shadow-md); } +.elev-lg { box-shadow: var(--shadow-lg); } + +/* — tags — */ +.tag { + display: inline-flex; align-items: center; font-size: 11px; + letter-spacing: 0.02em; padding: 3px 10px; + border-radius: calc(var(--radius-md) * 0.75); +} +.tag-accent { background: var(--color-accent-800); color: var(--color-accent-100); } +.tag-accent-2 { background: var(--color-accent-2-800); color: var(--color-accent-2-100); } +.tag-neutral { background: var(--color-neutral-800); color: var(--color-neutral-100); } +.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); } + +/* — navigation — */ +.nav { + display: flex; align-items: center; gap: var(--space-4); + padding: var(--space-3) var(--space-4); + border-bottom: none; +} +.nav-brand { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 18px; margin-right: auto; +} +.nav a { color: inherit; text-decoration: none; font-size: 14px; } +.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); } + +/* — tables — */ +.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th { + text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; + color: color-mix(in srgb, var(--color-text) 60%, transparent); + padding: var(--space-2); border-bottom: 1px solid transparent; /* keeps layout; the row paints the fading rule */ +} +.table td { + padding: var(--space-2); + border-bottom: 1px solid transparent; +} +/* Row rules as row-level strips so the end-fade spans the row, not each cell. */ +.table thead tr { + background: linear-gradient(to right, + transparent, var(--color-divider) 48px, + var(--color-divider) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px; +} +.table tbody tr { + background: linear-gradient(to right, + transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px, + color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px; +} +.table tbody tr:hover { + /* the hover tint layers over the row rule, which keeps painting */ + background: + linear-gradient(color-mix(in srgb, var(--color-text) 4%, transparent), + color-mix(in srgb, var(--color-text) 4%, transparent)) no-repeat 0 0 / 100% 100%, + linear-gradient(to right, + transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px, + color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px; +} + +/* — dialog — */ +.dialog-backdrop { + position: fixed; inset: 0; display: grid; place-items: center; + padding: var(--space-4); + background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent); +} +.dialog { + width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3); + padding: var(--space-4); border-radius: var(--radius-lg); + background: var(--color-surface); box-shadow: var(--shadow-lg); +} +.dialog-title { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 20px; +} +.dialog-body { font-size: 14px; opacity: 0.85; } +.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); } + diff --git a/frontend/public/fonts/Phosphor.woff2 b/frontend/public/fonts/Phosphor.woff2 new file mode 100644 index 0000000..76ff203 Binary files /dev/null and b/frontend/public/fonts/Phosphor.woff2 differ diff --git a/frontend/public/fonts/inter-latin-ext.woff2 b/frontend/public/fonts/inter-latin-ext.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/frontend/public/fonts/inter-latin-ext.woff2 differ diff --git a/frontend/public/fonts/inter-latin.woff2 b/frontend/public/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/frontend/public/fonts/inter-latin.woff2 differ diff --git a/frontend/public/index.html b/frontend/public/index.html index d4581a5..56a8d93 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -4,207 +4,290 @@
Gestion des présences par société
-Accès réservé — comptes créés par l'administrateur.