// services.js — page "Services" : répartition des coûts par service, tous // tenants/providers confondus, avec sélecteur de période (7j/30j/1 an) et // détail par tenant au clic sur un service. let currentRange = 30; let currentProvider = "all"; let currentServices = []; let currentTotal = 0; const SORT_COLUMNS = [ { key: "service_name", label: "Service", numeric: false }, { key: "amount", label: "Montant", numeric: true }, { key: "pct", label: "Part", numeric: true }, ]; let sortColumn = "amount"; let sortDirection = "desc"; async function loadServices(range, provider) { const params = new URLSearchParams({ range }); if (provider !== "all") params.set("provider", provider); const res = await fetch(`/api/services?${params}`); if (!res.ok) throw new Error(`API services: HTTP ${res.status}`); return res.json(); } function periodTitle(data) { if (data.mode === "monthly") return "12 derniers mois"; return rangeLabel(data.range_days); } function sortedServices() { const sorted = [...currentServices]; sorted.sort((a, b) => { const cmp = sortColumn === "service_name" ? a.service_name.localeCompare(b.service_name, "fr", { sensitivity: "base" }) : a[sortColumn] - b[sortColumn]; return sortDirection === "asc" ? cmp : -cmp; }); return sorted; } function sortIndicator(column) { if (sortColumn !== column) return ""; return sortDirection === "asc" ? " ↑" : " ↓"; } function renderServicesTable(data) { document.getElementById("services-period-title").textContent = periodTitle(data); currentServices = data.services; currentTotal = data.total; const wrap = document.getElementById("services-table-wrap"); if (currentServices.length === 0) { wrap.innerHTML = `
Aucune donnée de coût sur cette période. Lance collect.py ou import_historical.py pour peupler le dashboard.
`; return; } renderServicesTableRows(); } function renderServicesTableRows() { const wrap = document.getElementById("services-table-wrap"); const sorted = sortedServices(); const headerCells = SORT_COLUMNS.map((col) => { const classes = ["sortable"]; if (col.numeric) classes.push("num"); if (sortColumn === col.key) classes.push(`sorted-${sortDirection}`); return `${col.label}${sortIndicator(col.key)}`; }).join(""); const rows = sorted .map( (s) => ` ${escapeHtml(s.service_name)} ${formatEUR(s.amount)} ${s.pct.toFixed(1)} % ` ) .join(""); wrap.innerHTML = ` ${headerCells}${rows}
Total ${formatEUR(currentTotal)}
`; wrap.querySelectorAll("tr.clickable").forEach((tr) => { tr.addEventListener("click", () => openTenantDrawer(tr.dataset.service)); }); wrap.querySelectorAll("th.sortable").forEach((th) => { th.addEventListener("click", () => { const column = th.dataset.sort; if (sortColumn === column) { sortDirection = sortDirection === "asc" ? "desc" : "asc"; } else { sortColumn = column; sortDirection = column === "service_name" ? "asc" : "desc"; } renderServicesTableRows(); }); }); } async function reloadServices() { try { const data = await loadServices(currentRange, currentProvider); renderServicesTable(data); } catch (e) { document.getElementById("error-banner").innerHTML = ` `; } } function setupRangeToggle() { const buttons = document.querySelectorAll("#range-toggle button"); buttons.forEach((btn) => { btn.classList.toggle("active", Number(btn.dataset.range) === currentRange); btn.addEventListener("click", () => { currentRange = Number(btn.dataset.range); buttons.forEach((b) => b.classList.toggle("active", b === btn)); reloadServices(); }); }); } function setupProviderToggle() { const buttons = document.querySelectorAll("#provider-toggle button"); buttons.forEach((btn) => { btn.classList.toggle("active", btn.dataset.provider === currentProvider); btn.addEventListener("click", () => { currentProvider = btn.dataset.provider; buttons.forEach((b) => b.classList.toggle("active", b === btn)); reloadServices(); }); }); } // --- Drawer : détail par tenant d'un service ---------------------------- function openTenantDrawer(serviceName) { document.getElementById("drawer-title").textContent = serviceName; document.getElementById("drawer-sub").textContent = "Chargement…"; document.getElementById("drawer-body").innerHTML = `
Chargement…
`; document.getElementById("tenant-drawer").classList.add("open"); document.getElementById("tenant-drawer").setAttribute("aria-hidden", "false"); document.getElementById("drawer-backdrop").classList.add("open"); const params = new URLSearchParams({ range: currentRange }); if (currentProvider !== "all") params.set("provider", currentProvider); fetch(`/api/services/${encodeURIComponent(serviceName)}/tenants?${params}`) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => renderDrawerContent(data)) .catch((e) => { document.getElementById("drawer-sub").textContent = ""; document.getElementById("drawer-body").innerHTML = ``; }); } function renderDrawerContent(data) { const subEl = document.getElementById("drawer-sub"); const bodyEl = document.getElementById("drawer-body"); subEl.textContent = data.mode === "monthly" ? "12 derniers mois" : rangeLabel(data.range_days); if (!data.tenants.length) { bodyEl.innerHTML = `
Aucun tenant n'a de coût sur ce service pour cette période.
`; return; } bodyEl.innerHTML = data.tenants .map( (t) => `
${providerLabel(t.provider)} ${escapeHtml(t.tenant_name)}
${t.pct.toFixed(1)} %
${formatEUR(t.amount)}
` ) .join(""); bodyEl.querySelectorAll(".resource-row.clickable").forEach((row) => { row.addEventListener("click", () => { window.location.href = `/tenant/${row.dataset.provider}/${encodeURIComponent(row.dataset.name)}`; }); }); } function closeTenantDrawer() { document.getElementById("tenant-drawer").classList.remove("open"); document.getElementById("tenant-drawer").setAttribute("aria-hidden", "true"); document.getElementById("drawer-backdrop").classList.remove("open"); } function setupDrawer() { document.getElementById("drawer-close").addEventListener("click", closeTenantDrawer); document.getElementById("drawer-backdrop").addEventListener("click", closeTenantDrawer); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeTenantDrawer(); }); } async function init() { try { const data = await loadServices(currentRange, currentProvider); renderServicesTable(data); setupRangeToggle(); setupProviderToggle(); setupDrawer(); } catch (e) { document.getElementById("error-banner").innerHTML = ` `; document.getElementById("services-table-wrap").innerHTML = ""; } } init();