V7.9 — Tableau de bord et navigation regroupée pour le Service Enfance
Le Service Enfance ouvrait onze onglets à plat et atterrissait sur l'écran de saisie, alors que son travail commence par le contrôle. Les chiffres qui déclenchent une action vivaient sur trois écrans distincts. - Nouvelle page dashboard.php, réservée au Service Enfance et devenue sa page d'atterrissage : quatre tuiles d'action, les brouillons les plus anciens avec ouverture directe sur la bonne semaine, les plages de couverture les plus déficitaires, et le suivi des quotas annuels par agent. - Navigation regroupée par intention (Piloter / Planifier / Consulter / Paramétrer). Les rôles à quelques pages gardent la barre simple. - Sous-titre propre à chaque page : le bandeau générique n'apprenait rien. - Pastille « À valider » alimentée depuis toutes les pages, et non plus seulement depuis la page qu'elle annonce. Aucune règle métier n'est modifiée : le tableau de bord relit ce que PlanningModel, CoverageModel et QuotaModel calculent déjà, et son endpoint est gardé par requireService(). Le droit d'accès à la page vient de la migration V7.9 ; sans elle, l'onglet n'apparaît pas et homeUrl() se replie sur planning. Vérifié sur la base réelle : les chiffres de quota du tableau de bord sont identiques à ceux de api/annual_overview.php, les trois rôles rendent leurs écrans sans erreur, le rendu JS a été exécuté dans un DOM avec la réponse réelle de l'API, et la page ne produit aucun attribut style inline (CSP). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
329
assets/js/dashboard.js
Normal file
329
assets/js/dashboard.js
Normal file
@@ -0,0 +1,329 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const PTA = window.PTA;
|
||||
if (!PTA) throw new Error('PTA core doit être chargé avant dashboard.js');
|
||||
|
||||
const { utils } = PTA;
|
||||
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils;
|
||||
|
||||
const elements = {
|
||||
week: document.querySelector('#dashboard-week'),
|
||||
weekLabel: document.querySelector('#dashboard-week-label'),
|
||||
weekContext: document.querySelector('#dashboard-week-context'),
|
||||
refresh: document.querySelector('#dashboard-refresh'),
|
||||
message: document.querySelector('#dashboard-message'),
|
||||
loading: document.querySelector('#dashboard-loading'),
|
||||
|
||||
pendingCount: document.querySelector('#dashboard-pending-count'),
|
||||
pendingDetail: document.querySelector('#dashboard-pending-detail'),
|
||||
pendingTile: document.querySelector('#dashboard-tile-pending'),
|
||||
pendingList: document.querySelector('#dashboard-pending-list'),
|
||||
pendingEmpty: document.querySelector('#dashboard-pending-empty'),
|
||||
|
||||
coverageCount: document.querySelector('#dashboard-coverage-count'),
|
||||
coverageDetail: document.querySelector('#dashboard-coverage-detail'),
|
||||
coverageTile: document.querySelector('#dashboard-tile-coverage'),
|
||||
coverageList: document.querySelector('#dashboard-coverage-list'),
|
||||
coverageEmpty: document.querySelector('#dashboard-coverage-empty'),
|
||||
coverageLink: document.querySelector('#dashboard-coverage-link'),
|
||||
coverageUnconfigured: document.querySelector('#dashboard-coverage-unconfigured'),
|
||||
|
||||
quotaCount: document.querySelector('#dashboard-quota-count'),
|
||||
quotaDetail: document.querySelector('#dashboard-quota-detail'),
|
||||
quotaTile: document.querySelector('#dashboard-tile-quota'),
|
||||
quotaSubtitle: document.querySelector('#dashboard-quota-subtitle'),
|
||||
quotaAgents: document.querySelector('#dashboard-quota-agents'),
|
||||
quotaRate: document.querySelector('#dashboard-quota-rate'),
|
||||
quotaRateDetail: document.querySelector('#dashboard-quota-rate-detail'),
|
||||
quotaRemaining: document.querySelector('#dashboard-quota-remaining'),
|
||||
quotaEmptyAgents: document.querySelector('#dashboard-quota-empty'),
|
||||
quotaBody: document.querySelector('#dashboard-quota-body'),
|
||||
quotaEmptyState: document.querySelector('#dashboard-quota-empty-state'),
|
||||
|
||||
setupCount: document.querySelector('#dashboard-setup-count'),
|
||||
setupDetail: document.querySelector('#dashboard-setup-detail'),
|
||||
setupTile: document.querySelector('#dashboard-tile-setup'),
|
||||
|
||||
navBadge: document.querySelector('#pending-validation-tab-badge'),
|
||||
};
|
||||
|
||||
let pendingItems = [];
|
||||
let loading = false;
|
||||
|
||||
function setMessage(text = '', type = 'info') {
|
||||
if (!elements.message) return;
|
||||
elements.message.className = text ? `message message-${type}` : 'message';
|
||||
elements.message.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Une tuile est neutre quand il n'y a rien à faire, alertante dès qu'un
|
||||
* compteur remonte. La couleur ne dit rien de plus que le chiffre : elle
|
||||
* évite juste d'avoir à lire les quatre tuiles pour savoir où agir.
|
||||
*/
|
||||
function setTileTone(tile, count) {
|
||||
if (!tile) return;
|
||||
tile.classList.toggle('is-alert', Number(count) > 0);
|
||||
tile.classList.toggle('is-clear', Number(count) === 0);
|
||||
}
|
||||
|
||||
function plural(count, singular, pluralForm) {
|
||||
return Number(count) > 1 ? pluralForm : singular;
|
||||
}
|
||||
|
||||
function weekParam() {
|
||||
return elements.week?.value || '';
|
||||
}
|
||||
|
||||
function weekValueOf(item) {
|
||||
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function setNavBadge(count) {
|
||||
if (!elements.navBadge) return;
|
||||
elements.navBadge.textContent = String(count);
|
||||
elements.navBadge.hidden = Number(count) === 0;
|
||||
elements.navBadge.setAttribute(
|
||||
'aria-label',
|
||||
`${count} planning${Number(count) > 1 ? 's' : ''} en attente de validation`
|
||||
);
|
||||
}
|
||||
|
||||
function renderWeek(week) {
|
||||
if (!week) return;
|
||||
const contexteLabel = {
|
||||
PERISCOLAIRE: 'Semaine scolaire — contexte périscolaire',
|
||||
EXTRASCOLAIRE: 'Semaine de vacances — contexte extrascolaire',
|
||||
MIXTE: 'Semaine à cheval entre période scolaire et vacances',
|
||||
}[week.contexte] || '';
|
||||
|
||||
if (elements.weekLabel) {
|
||||
elements.weekLabel.textContent =
|
||||
`Semaine ${week.numero} · du ${formatDate(week.date_debut)} au ${formatDate(week.date_fin)}`;
|
||||
}
|
||||
if (elements.weekContext) {
|
||||
const vacances = Array.isArray(week.vacances_libelles) ? week.vacances_libelles : [];
|
||||
const suffix = vacances.length ? ` (${vacances.join(', ')})` : '';
|
||||
elements.weekContext.textContent = contexteLabel ? `${contexteLabel}${suffix}` : '';
|
||||
elements.weekContext.hidden = !contexteLabel;
|
||||
}
|
||||
if (elements.coverageLink) {
|
||||
elements.coverageLink.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
|
||||
}
|
||||
if (elements.coverageTile) {
|
||||
elements.coverageTile.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPending(validation) {
|
||||
const count = Number(validation.count || 0);
|
||||
pendingItems = Array.isArray(validation.apercu) ? validation.apercu : [];
|
||||
|
||||
setNavBadge(count);
|
||||
setTileTone(elements.pendingTile, count);
|
||||
if (elements.pendingCount) elements.pendingCount.textContent = String(count);
|
||||
if (elements.pendingDetail) {
|
||||
elements.pendingDetail.textContent = count === 0
|
||||
? 'Aucun brouillon en attente'
|
||||
: `${formatDuration(Number(validation.minutes_decomptees || 0))} décomptées · `
|
||||
+ `${validation.agent_count} ${plural(validation.agent_count, 'agent', 'agents')} · `
|
||||
+ `${validation.structure_count} ${plural(validation.structure_count, 'lieu', 'lieux')}`;
|
||||
}
|
||||
|
||||
if (elements.pendingEmpty) elements.pendingEmpty.hidden = count !== 0;
|
||||
if (!elements.pendingList) return;
|
||||
|
||||
elements.pendingList.innerHTML = pendingItems.map((item) => `
|
||||
<li class="dashboard-list-item">
|
||||
<div class="dashboard-list-main">
|
||||
<strong>${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)}</strong>
|
||||
<small>${escapeHtml(item.structure_nom)} · semaine ${Number(item.numero_semaine)} du ${formatDate(item.date_debut)}</small>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span>${Number(item.nombre_creneaux)} ${plural(item.nombre_creneaux, 'créneau', 'créneaux')}</span>
|
||||
<strong>${formatDuration(Number(item.total_minutes || 0))}</strong>
|
||||
</div>
|
||||
<button class="button button-success dashboard-list-action" type="button" data-planning-id="${Number(item.id_planning)}">
|
||||
Contrôler
|
||||
</button>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderCoverage(coverage, week) {
|
||||
const gapCount = Number(coverage.gap_count || 0);
|
||||
const unconfigured = Number(coverage.unconfigured_count || 0);
|
||||
|
||||
setTileTone(elements.coverageTile, gapCount);
|
||||
setTileTone(elements.setupTile, unconfigured);
|
||||
|
||||
if (elements.coverageCount) elements.coverageCount.textContent = String(gapCount);
|
||||
if (elements.coverageDetail) {
|
||||
elements.coverageDetail.textContent = gapCount === 0
|
||||
? 'Tous les minimums sont atteints'
|
||||
: `${formatDuration(Number(coverage.gap_minutes || 0))} non couvertes · `
|
||||
+ `${coverage.structure_count} ${plural(coverage.structure_count, 'lieu concerné', 'lieux concernés')}`;
|
||||
}
|
||||
if (elements.setupCount) elements.setupCount.textContent = String(unconfigured);
|
||||
if (elements.setupDetail) {
|
||||
elements.setupDetail.textContent = unconfigured === 0
|
||||
? 'Tous les lieux ont des horaires attendus'
|
||||
: 'Aucun minimum d’agents n’y est contrôlé';
|
||||
}
|
||||
|
||||
const items = Array.isArray(coverage.apercu) ? coverage.apercu : [];
|
||||
if (elements.coverageEmpty) elements.coverageEmpty.hidden = items.length !== 0;
|
||||
if (elements.coverageList) {
|
||||
const weekValue = week?.valeur || '';
|
||||
elements.coverageList.innerHTML = items.map((gap) => {
|
||||
const href = `coverage.php?week=${encodeURIComponent(weekValue)}&structure_id=${Number(gap.id_structure)}`;
|
||||
return `
|
||||
<li class="dashboard-list-item">
|
||||
<div class="dashboard-list-main">
|
||||
<strong>${escapeHtml(gap.structure_nom)}</strong>
|
||||
<small>${escapeHtml(gap.jour_libelle)} ${formatDate(gap.date)} · ${escapeHtml(gap.heure_debut)} → ${escapeHtml(gap.heure_fin)}</small>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span>${Number(gap.agents_planifies)} / ${Number(gap.minimum_agents)} agents</span>
|
||||
<strong class="dashboard-gap-count">−${Number(gap.agents_manquants)}</strong>
|
||||
</div>
|
||||
<a class="button button-secondary dashboard-list-action" href="${href}">Analyser</a>
|
||||
</li>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (elements.coverageUnconfigured) {
|
||||
const list = Array.isArray(coverage.unconfigured) ? coverage.unconfigured : [];
|
||||
if (list.length === 0) {
|
||||
elements.coverageUnconfigured.hidden = true;
|
||||
elements.coverageUnconfigured.textContent = '';
|
||||
} else {
|
||||
const names = list.map((structure) => escapeHtml(structure.nom)).join(', ');
|
||||
const reste = unconfigured - list.length;
|
||||
elements.coverageUnconfigured.innerHTML =
|
||||
`<strong>Non contrôlés :</strong> ${names}${reste > 0 ? ` et ${reste} autre${reste > 1 ? 's' : ''}` : ''}. `
|
||||
+ 'Définissez leurs horaires de couverture dans l’administration.';
|
||||
elements.coverageUnconfigured.hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuota(quota) {
|
||||
const overCount = Number(quota.depassement_count || 0);
|
||||
setTileTone(elements.quotaTile, overCount);
|
||||
|
||||
if (elements.quotaCount) elements.quotaCount.textContent = String(overCount);
|
||||
if (elements.quotaDetail) {
|
||||
elements.quotaDetail.textContent = overCount === 0
|
||||
? `Aucun dépassement sur ${quota.annee}`
|
||||
: `Sur ${quota.agent_count} ${plural(quota.agent_count, 'agent suivi', 'agents suivis')} en ${quota.annee}`;
|
||||
}
|
||||
if (elements.quotaTile) {
|
||||
elements.quotaTile.href = `annual.php?year=${Number(quota.annee)}`;
|
||||
}
|
||||
if (elements.quotaSubtitle) {
|
||||
elements.quotaSubtitle.textContent =
|
||||
`Année civile ${quota.annee} · heures validées et brouillon comprises, motifs décomptés uniquement.`;
|
||||
}
|
||||
if (elements.quotaAgents) elements.quotaAgents.textContent = String(quota.agent_count || 0);
|
||||
if (elements.quotaRate) elements.quotaRate.textContent = `${Number(quota.taux_global || 0)} %`;
|
||||
if (elements.quotaRateDetail) {
|
||||
elements.quotaRateDetail.textContent =
|
||||
`${formatDuration(Number(quota.minutes_affectees || 0))} sur ${formatDuration(Number(quota.minutes_cible || 0))}`;
|
||||
}
|
||||
if (elements.quotaRemaining) elements.quotaRemaining.textContent = formatDuration(Number(quota.minutes_restantes || 0));
|
||||
if (elements.quotaEmptyAgents) elements.quotaEmptyAgents.textContent = String(quota.sans_heure_count || 0);
|
||||
|
||||
const items = Array.isArray(quota.apercu) ? quota.apercu : [];
|
||||
if (elements.quotaEmptyState) elements.quotaEmptyState.hidden = items.length !== 0;
|
||||
if (!elements.quotaBody) return;
|
||||
|
||||
elements.quotaBody.innerHTML = items.map((agent) => {
|
||||
const over = Number(agent.minutes_restantes) < 0;
|
||||
const rate = Number(agent.taux_consommation || 0);
|
||||
const href = `annual.php?agent_id=${Number(agent.id_agent)}&year=${Number(quota.annee)}`;
|
||||
return `
|
||||
<tr class="${over ? 'is-over-quota' : ''}">
|
||||
<td>
|
||||
<strong>${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong>
|
||||
<small>${escapeHtml(agent.poste_libelle || 'Poste non renseigné')}</small>
|
||||
</td>
|
||||
<td>${escapeHtml(agent.structure_nom || 'Non rattaché')}</td>
|
||||
<td>
|
||||
<strong>${formatDuration(Number(agent.quota_cible_minutes || 0))}</strong>
|
||||
<small>quotité ${Number(agent.quotite_travail)} %</small>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${formatDuration(Number(agent.minutes_affectees || 0))}</strong>
|
||||
<small>${formatDuration(Number(agent.minutes_brouillon || 0))} en brouillon</small>
|
||||
</td>
|
||||
<td class="dashboard-quota-gauge-cell">
|
||||
<progress class="dashboard-quota-gauge${over ? ' is-over' : ''}" max="100" value="${Math.min(100, Math.max(0, rate))}" aria-hidden="true"></progress>
|
||||
<span>${rate} %</span>
|
||||
</td>
|
||||
<td class="dashboard-quota-action-cell">
|
||||
<a class="button button-secondary" href="${href}">Ouvrir</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openPendingPlanning(item) {
|
||||
if (!item) return;
|
||||
const params = new URLSearchParams({
|
||||
structure_id: String(item.id_structure),
|
||||
agent_id: String(item.id_agent),
|
||||
week: weekValueOf(item),
|
||||
focus: 'validation',
|
||||
notice: `Planning de ${item.agent_prenom} ${item.agent_nom} ouvert depuis le tableau de bord.`,
|
||||
});
|
||||
window.location.href = `planning.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function load(options = {}) {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
if (!options.silent) setMessage('');
|
||||
if (elements.loading) elements.loading.hidden = false;
|
||||
if (elements.refresh) elements.refresh.disabled = true;
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
const week = weekParam();
|
||||
if (week) query.set('week', week);
|
||||
|
||||
const response = await fetch(`api/dashboard_summary.php?${query.toString()}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await readJsonResponse(response);
|
||||
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le tableau de bord.'));
|
||||
|
||||
renderWeek(data.semaine);
|
||||
renderPending(data.validation || {});
|
||||
renderCoverage(data.couverture || {}, data.semaine);
|
||||
renderQuota(data.quota || {});
|
||||
} catch (error) {
|
||||
setMessage(error.message, 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
if (elements.loading) elements.loading.hidden = true;
|
||||
if (elements.refresh) elements.refresh.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
elements.refresh?.addEventListener('click', () => load());
|
||||
elements.week?.addEventListener('change', () => load());
|
||||
elements.pendingList?.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest('[data-planning-id]');
|
||||
if (!trigger) return;
|
||||
const id = Number(trigger.dataset.planningId);
|
||||
openPendingPlanning(pendingItems.find((item) => Number(item.id_planning) === id));
|
||||
});
|
||||
|
||||
load({ silent: true });
|
||||
}
|
||||
|
||||
PTA.modules.dashboard = { init, load, refresh: load };
|
||||
})();
|
||||
@@ -73,8 +73,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastille « À valider » de la barre de navigation.
|
||||
*
|
||||
* Elle n'était alimentée que sur la page « À valider » elle-même, donc
|
||||
* invisible là où elle sert : depuis n'importe quel écran, le service voit
|
||||
* maintenant si des brouillons attendent. La requête est volontairement
|
||||
* réduite à un compteur, et son échec est silencieux — une commodité ne
|
||||
* doit jamais empêcher une page de fonctionner.
|
||||
*/
|
||||
async function refreshNavigationCounters() {
|
||||
const badge = document.querySelector('#pending-validation-tab-badge');
|
||||
if (!badge) return;
|
||||
// Les écrans qui chargent déjà ce compteur s'en chargent eux-mêmes.
|
||||
if (PTA.modules.pendingValidation || PTA.modules.dashboard) return;
|
||||
if (!PTA.config?.permissions?.can_view_pending) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('api/dashboard_summary.php?scope=badges', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await PTA.utils.readJsonResponse(response);
|
||||
const count = Number(data?.validation?.count || 0);
|
||||
badge.textContent = String(count);
|
||||
badge.hidden = count === 0;
|
||||
badge.setAttribute('aria-label', `${count} planning${count > 1 ? 's' : ''} en attente de validation`);
|
||||
} catch (error) {
|
||||
badge.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
initialisePlanning();
|
||||
initialiseStructureOverview();
|
||||
initialiseAgentOverview();
|
||||
initialiseCoverage();
|
||||
refreshNavigationCounters();
|
||||
})();
|
||||
|
||||
267
assets/style.css
267
assets/style.css
@@ -2553,3 +2553,270 @@ textarea:disabled {
|
||||
@media print {
|
||||
.annual-validation-card { display: none !important; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PTA V7.9 - Navigation regroupée et tableau de bord
|
||||
============================================================ */
|
||||
|
||||
/* --- Barre de navigation par intention --------------------- */
|
||||
.view-tabs-grouped {
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0.45rem 0.35rem 0.4rem;
|
||||
}
|
||||
|
||||
.view-tab-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.6rem;
|
||||
}
|
||||
|
||||
.view-tabs-grouped .view-tab-group + .view-tab-group {
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.view-tab-group-label {
|
||||
padding-left: 0.35rem;
|
||||
color: #7b8794;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-tab-group-items {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.view-tabs-grouped .view-tab {
|
||||
padding: 0.55rem 0.8rem;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* Sans titres de groupe, la barre retrouve son alignement d'origine. */
|
||||
.view-tabs:not(.view-tabs-grouped) .view-tab-group,
|
||||
.view-tabs:not(.view-tabs-grouped) .view-tab-group-items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.page-header-identity { min-width: 0; }
|
||||
.page-header-identity .subtitle { max-width: 68ch; }
|
||||
|
||||
/* --- Tuiles d'action --------------------------------------- */
|
||||
.dashboard-context-card .section-heading { align-items: flex-end; }
|
||||
.dashboard-context-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.dashboard-week-field { min-width: 190px; }
|
||||
.dashboard-context-note {
|
||||
margin: 14px 0 0;
|
||||
padding: 10px 13px;
|
||||
border-radius: 10px;
|
||||
border-left: 5px solid #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1e3a5f;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dashboard-tile {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e4e8eb;
|
||||
border-left: 6px solid #cbd5e1;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 24px rgba(23, 32, 42, 0.05);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.dashboard-tile:hover,
|
||||
.dashboard-tile:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px rgba(23, 32, 42, 0.11);
|
||||
}
|
||||
.dashboard-tile-label {
|
||||
color: #5f6b7a;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dashboard-tile-value { font-size: 2.1rem; line-height: 1.1; }
|
||||
.dashboard-tile-detail { color: #66737d; font-size: 0.84rem; }
|
||||
.dashboard-tile-cta {
|
||||
margin-top: 6px;
|
||||
color: #1f4c73;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.dashboard-tile-cta::after { content: ' →'; }
|
||||
|
||||
.dashboard-tile.is-alert { border-left-color: #c72f3f; }
|
||||
.dashboard-tile.is-alert .dashboard-tile-value { color: #a3212f; }
|
||||
.dashboard-tile.is-clear { border-left-color: #2f855a; }
|
||||
.dashboard-tile.is-clear .dashboard-tile-value { color: #256d3b; }
|
||||
|
||||
/* --- Listes « à traiter » ---------------------------------- */
|
||||
.dashboard-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.dashboard-columns > .card { margin-bottom: 0; }
|
||||
.dashboard-list-card { display: flex; flex-direction: column; }
|
||||
|
||||
.dashboard-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.dashboard-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #e6ebee;
|
||||
border-radius: 11px;
|
||||
background: #f9fbfc;
|
||||
}
|
||||
.dashboard-list-main { flex: 1 1 auto; min-width: 0; }
|
||||
.dashboard-list-main strong { display: block; }
|
||||
.dashboard-list-main small { color: #66737d; font-size: 0.82rem; }
|
||||
.dashboard-list-meta {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dashboard-list-meta span { color: #66737d; font-size: 0.78rem; }
|
||||
.dashboard-list-action {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.86rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dashboard-gap-count { color: #a3212f; }
|
||||
|
||||
.dashboard-inline-note {
|
||||
margin-top: 14px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 10px;
|
||||
border-left: 5px solid #d97706;
|
||||
background: #fffbeb;
|
||||
color: #7d5c00;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* --- Suivi des quotas -------------------------------------- */
|
||||
.dashboard-quota-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.dashboard-quota-table-wrap { overflow-x: auto; }
|
||||
.dashboard-quota-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.dashboard-quota-table th,
|
||||
.dashboard-quota-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eceff1;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dashboard-quota-table th {
|
||||
color: #5f6b7a;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dashboard-quota-table td small { display: block; color: #66737d; font-size: 0.78rem; }
|
||||
.dashboard-quota-table tr.is-over-quota { background: #fef4f4; }
|
||||
.dashboard-quota-action-cell { text-align: right; }
|
||||
.dashboard-quota-action-cell .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 7px 13px;
|
||||
font-size: 0.84rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-quota-gauge-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Jauge native : la CSP interdit l'attribut style, la largeur ne peut donc pas
|
||||
être calculée en JavaScript sur une simple div. */
|
||||
.dashboard-quota-gauge {
|
||||
width: 110px;
|
||||
height: 9px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #e4e9ec;
|
||||
appearance: none;
|
||||
}
|
||||
.dashboard-quota-gauge::-webkit-progress-bar { background: #e4e9ec; border-radius: 999px; }
|
||||
.dashboard-quota-gauge::-webkit-progress-value { background: #2f6f9f; border-radius: 999px; }
|
||||
.dashboard-quota-gauge::-moz-progress-bar { background: #2f6f9f; border-radius: 999px; }
|
||||
.dashboard-quota-gauge.is-over::-webkit-progress-value { background: #c72f3f; }
|
||||
.dashboard-quota-gauge.is-over::-moz-progress-bar { background: #c72f3f; }
|
||||
|
||||
/* --- Raccourcis -------------------------------------------- */
|
||||
.dashboard-shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.dashboard-shortcut {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e4e8eb;
|
||||
border-radius: 12px;
|
||||
background: #f9fbfc;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dashboard-shortcut:hover,
|
||||
.dashboard-shortcut:focus-visible { border-color: #b9c6cf; background: #fff; }
|
||||
.dashboard-shortcut small { color: #66737d; font-size: 0.82rem; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.view-tabs-grouped { flex-direction: column; align-items: flex-start; gap: 0.6rem; }
|
||||
.view-tabs-grouped .view-tab-group + .view-tab-group { border-left: 0; }
|
||||
.view-tab-group { width: 100%; padding: 0; }
|
||||
.view-tab-group-items { flex-wrap: wrap; }
|
||||
.dashboard-context-card .section-heading { align-items: stretch; }
|
||||
.dashboard-context-actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.dashboard-list-item { flex-wrap: wrap; }
|
||||
.dashboard-list-meta { text-align: left; }
|
||||
.dashboard-list-action { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user