Files
botino/public/components/home-dashboard.js
Lucas Tettamanti c410133c4c home: fix scroll vertical (host sin height definido)
:host no tenía display/height, así que min-height:100% del .container no
encontraba contenedor de referencia y los padres .view/.layout-crud
(overflow:hidden) recortaban el contenido. Movido el overflow-y:auto al
:host con height:100% explícito.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:40:36 -03:00

588 lines
17 KiB
JavaScript

import { api } from "../lib/api.js";
function formatCurrency(value) {
if (value == null) return "$0";
return new Intl.NumberFormat("es-AR", { style: "currency", currency: "ARS", maximumFractionDigits: 0 }).format(value);
}
function formatNumber(value) {
if (value == null) return "0";
return new Intl.NumberFormat("es-AR", { maximumFractionDigits: 1 }).format(value);
}
/** Lee una CSS custom property del :root. Fallback al hex provisto. */
function cssVar(name, fallback = "") {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
/** rgba con alpha desde un hex (#RRGGBB) o sky/etc — para fills de charts. */
function withAlpha(hex, alpha) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
if (!m) return hex;
const n = parseInt(m[1], 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
class HomeDashboard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.stats = null;
this.loading = false;
this.charts = {};
this.shadowRoot.innerHTML = `
<style>
:host { display:block; height:100%; min-height:0; overflow-y:auto; font-family: var(--font-sans); }
* { box-sizing: border-box; }
.container {
background: var(--bg);
color: var(--text);
padding: var(--space-6);
}
.header {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: var(--space-6);
}
.header h1 {
font-size: var(--fs-xl);
font-weight: var(--fw-semibold);
letter-spacing: -0.02em;
margin: 0;
}
.sync-info { font-size: var(--fs-sm); color: var(--text-muted); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: var(--space-6);
}
.chart-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.chart-card.full-width { grid-column: 1 / -1; }
.chart-title {
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--space-4);
}
.chart-container { position: relative; height: 260px; }
.chart-container.tall { height: 320px; }
.chart-container.short { height: 200px; }
.loading {
display: flex; align-items: center; justify-content: center;
height: 200px; color: var(--text-muted);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.kpi-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.kpi-value {
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.kpi-label {
font-size: var(--fs-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: var(--fw-medium);
}
.donut-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-4);
}
canvas { max-width: 100%; }
</style>
<div class="container">
<div class="header">
<h1>Dashboard de Ventas</h1>
<div class="sync-info"></div>
</div>
<div class="kpi-row"></div>
<div class="grid">
<div class="chart-card full-width">
<div class="chart-title">Ventas Totales por Mes</div>
<div class="chart-container tall">
<canvas id="monthly-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Web vs WhatsApp</div>
<div class="chart-container">
<canvas id="source-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Comparativa Año a Año</div>
<div class="chart-container">
<canvas id="yoy-chart"></canvas>
</div>
</div>
<div class="donut-row">
<div class="chart-card">
<div class="chart-title">Por Canal</div>
<div class="chart-container short">
<canvas id="source-donut"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Delivery vs Retiro</div>
<div class="chart-container short">
<canvas id="shipping-donut"></canvas>
</div>
</div>
</div>
<div class="chart-card full-width">
<div class="chart-title">Top Productos por Facturación</div>
<div class="chart-container tall">
<canvas id="products-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Top por Kg Vendidos</div>
<div class="chart-container">
<canvas id="kg-chart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="chart-title">Top por Unidades</div>
<div class="chart-container">
<canvas id="units-chart"></canvas>
</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
this.loadStats();
}
disconnectedCallback() {
// Destruir charts para liberar memoria
Object.values(this.charts).forEach(chart => chart?.destroy?.());
this.charts = {};
}
async loadStats() {
this.loading = true;
try {
this.stats = await api.getOrderStats();
this.render();
} catch (err) {
console.error("[home-dashboard] loadStats error:", err);
} finally {
this.loading = false;
}
}
render() {
if (!this.stats) return;
// Actualizar sync info
const syncInfo = this.shadowRoot.querySelector(".sync-info");
syncInfo.textContent = `${this.stats.total_in_cache || 0} pedidos en cache`;
if (this.stats.synced > 0) {
syncInfo.textContent += ` (${this.stats.synced} nuevos sincronizados)`;
}
// Renderizar KPIs
this.renderKPIs();
// Renderizar charts
this.renderMonthlyChart();
this.renderSourceChart();
this.renderYoyChart();
this.renderDonuts();
this.renderProductsChart();
this.renderKgChart();
this.renderUnitsChart();
}
renderKPIs() {
const totals = this.stats.totals_aggregated || {};
const kpiRow = this.shadowRoot.querySelector(".kpi-row");
kpiRow.innerHTML = `
<div class="kpi-card">
<div class="kpi-value" style="color: var(--chart-blue)">${formatCurrency(totals.total_revenue)}</div>
<div class="kpi-label">Total Facturado</div>
</div>
<div class="kpi-card">
<div class="kpi-value">${formatNumber(totals.total_orders)}</div>
<div class="kpi-label">Pedidos</div>
</div>
<div class="kpi-card">
<div class="kpi-value" style="color: var(--chart-green)">${formatCurrency(totals.by_source?.whatsapp)}</div>
<div class="kpi-label">WhatsApp</div>
</div>
<div class="kpi-card">
<div class="kpi-value" style="color: var(--chart-blue)">${formatCurrency(totals.by_source?.web)}</div>
<div class="kpi-label">Web</div>
</div>
`;
}
renderMonthlyChart() {
const ctx = this.shadowRoot.getElementById("monthly-chart");
if (!ctx) return;
if (this.charts.monthly) this.charts.monthly.destroy();
const months = this.stats.months || [];
const totals = this.stats.totals || [];
this.charts.monthly = new Chart(ctx, {
type: "bar",
data: {
labels: months.map(m => {
const [y, mo] = m.split("-");
return `${mo}/${y.slice(2)}`;
}),
datasets: [{
label: "Ventas",
data: totals,
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.7),
borderColor: cssVar("--chart-blue"),
borderWidth: 1,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
x: {
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderSourceChart() {
const ctx = this.shadowRoot.getElementById("source-chart");
if (!ctx) return;
if (this.charts.source) this.charts.source.destroy();
const months = this.stats.months || [];
const waData = this.stats.by_source?.whatsapp || [];
const webData = this.stats.by_source?.web || [];
this.charts.source = new Chart(ctx, {
type: "bar",
data: {
labels: months.map(m => {
const [y, mo] = m.split("-");
return `${mo}/${y.slice(2)}`;
}),
datasets: [
{
label: "WhatsApp",
data: waData,
backgroundColor: cssVar("--chart-green"),
},
{
label: "Web",
data: webData,
backgroundColor: cssVar("--chart-blue"),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: cssVar("--text-muted") },
},
},
scales: {
y: {
stacked: true,
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
x: {
stacked: true,
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderYoyChart() {
const ctx = this.shadowRoot.getElementById("yoy-chart");
if (!ctx) return;
if (this.charts.yoy) this.charts.yoy.destroy();
const yoy = this.stats.yoy || {};
this.charts.yoy = new Chart(ctx, {
type: "line",
data: {
labels: yoy.months || [],
datasets: [
{
label: String(yoy.current_year || "Actual"),
data: yoy.current_year_data || [],
borderColor: cssVar("--chart-blue"),
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.15),
fill: true,
tension: 0.3,
},
{
label: String(yoy.last_year || "Anterior"),
data: yoy.last_year_data || [],
borderColor: cssVar("--chart-gray"),
backgroundColor: withAlpha(cssVar("--chart-gray"), 0.15),
fill: true,
tension: 0.3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: cssVar("--text-muted") },
},
},
scales: {
y: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
x: {
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderDonuts() {
const totals = this.stats.totals_aggregated || {};
// Source donut
const sourceCtx = this.shadowRoot.getElementById("source-donut");
if (sourceCtx) {
if (this.charts.sourceDonut) this.charts.sourceDonut.destroy();
this.charts.sourceDonut = new Chart(sourceCtx, {
type: "doughnut",
data: {
labels: ["WhatsApp", "Web"],
datasets: [{
data: [totals.by_source?.whatsapp || 0, totals.by_source?.web || 0],
backgroundColor: [cssVar("--chart-green"), cssVar("--chart-blue")],
}],
},
options: this.getDonutOptions(),
});
}
// Shipping donut
const shippingCtx = this.shadowRoot.getElementById("shipping-donut");
if (shippingCtx) {
if (this.charts.shippingDonut) this.charts.shippingDonut.destroy();
this.charts.shippingDonut = new Chart(shippingCtx, {
type: "doughnut",
data: {
labels: ["Delivery", "Retiro"],
datasets: [{
data: [totals.by_shipping?.delivery || 0, totals.by_shipping?.pickup || 0],
backgroundColor: [cssVar("--chart-purple"), cssVar("--chart-orange")],
}],
},
options: this.getDonutOptions(),
});
}
}
getDonutOptions() {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom",
labels: { color: cssVar("--text-muted") },
},
},
};
}
renderProductsChart() {
const ctx = this.shadowRoot.getElementById("products-chart");
if (!ctx) return;
if (this.charts.products) this.charts.products.destroy();
const products = this.stats.top_products_revenue || [];
const labels = products.map(p => p.name?.slice(0, 30) || "Sin nombre");
const data = products.map(p => p.revenue || 0);
this.charts.products = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Facturación",
data,
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.7),
borderColor: cssVar("--chart-blue"),
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
y: {
ticks: { color: cssVar("--text-muted") },
grid: { display: false },
},
},
},
});
}
renderKgChart() {
const ctx = this.shadowRoot.getElementById("kg-chart");
if (!ctx) return;
if (this.charts.kg) this.charts.kg.destroy();
const products = this.stats.top_products_kg || [];
const labels = products.map(p => p.name?.slice(0, 25) || "Sin nombre");
const data = products.map(p => p.kg || 0);
this.charts.kg = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Kg",
data,
backgroundColor: withAlpha(cssVar("--chart-purple"), 0.7),
borderColor: cssVar("--chart-purple"),
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
y: {
ticks: { color: cssVar("--text-muted"), font: { size: 10 } },
grid: { display: false },
},
},
},
});
}
renderUnitsChart() {
const ctx = this.shadowRoot.getElementById("units-chart");
if (!ctx) return;
if (this.charts.units) this.charts.units.destroy();
const products = this.stats.top_products_units || [];
const labels = products.map(p => p.name?.slice(0, 25) || "Sin nombre");
const data = products.map(p => p.units || 0);
this.charts.units = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [{
label: "Unidades",
data,
backgroundColor: withAlpha(cssVar("--chart-orange"), 0.7),
borderColor: cssVar("--chart-orange"),
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: cssVar("--text-muted") },
grid: { color: cssVar("--border") },
},
y: {
ticks: { color: cssVar("--text-muted"), font: { size: 10 } },
grid: { display: false },
},
},
},
});
}
}
customElements.define("home-dashboard", HomeDashboard);