Files
botino/public/components/home-dashboard.js
Lucas Tettamanti 6376739f48 Frontend: scroll fix, SSE reconnect, toast global, stale states, theming
5 bloques en una pasada:

A. Scroll fixes (síntoma reportado por el usuario):
   - run-timeline: nuevo flag _userScrolledUp con detección por rAF en
     bindScroll. Auto-scroll al final SOLO si el usuario está abajo
     (umbral 150px) o si él mismo disparó un optimistic bubble. Cuando
     el usuario manda mensaje, se resetea el flag y se scrollea.
   - conversation-inspector: mismo patrón. ui:chatScroll respeta el
     flag userScrolledUp del emisor para no perseguir al usuario que
     lee arriba.
   - .bubble: + min-width:0, overflow-wrap:anywhere para URLs/JSON
     largos sin espacios.
   - <pre>: + overflow-x:auto, max-width:100%.
   - chat-simulator: textarea con resize:vertical, min/max heights
     viewport-friendly. inputs-col con overflow-y:auto.

B. Stale state options:
   - conversation-list y conversations-crud: dropdowns ahora muestran
     IDLE / CART / SHIPPING / AWAITING_HUMAN. Quitados BROWSING,
     BUILDING_ORDER, WAITING_ADDRESS, WAITING_PAYMENT, COMPLETED.
   - main.js: simulated plan.next_state pasa a CART.

C. SSE resilience (lib/sse.js):
   - connect() con backoff exponencial (1s → 30s).
   - safeParse() helper: cada evento envuelve JSON.parse en try-catch
     para que un payload malformado no rompa otros listeners.
   - reset retryDelay al primer "hello" exitoso.
   - ops-shell: indicador con dot (verde "En vivo" / naranja pulsante
     "Reconectando…") en lugar de texto plano.

D. Toast service global (public/lib/toast.js):
   - API simple: toast({ kind, text, ms }). Apila, animación slide-in,
     auto-dismiss 4s. Click para cerrar.
   - safeFetch en api.js: wrapper que dispara toast en network error y
     non-OK. Migrados simEvolution + retryLast.
   - chat-simulator usa toast en lugar de status text efímero.

E. Theming con CSS vars:
   - public/styles/theme.css con paleta completa (panels, borders,
     text, accents, bubbles, charts, radii, shadows). Linkeado desde
     index.html.
   - Migrados a var(--*) los 5 componentes más visibles:
     run-timeline, chat-simulator, conversation-inspector,
     conversation-list, home-dashboard. Custom properties heredan a
     través del shadow DOM, así que los demás componentes pueden
     migrar gradualmente sin cambios estructurales.
   - home-dashboard ya tenía vars locales: ahora apuntan a las globales.

Backend: 192/192 tests pasando. Sin cambios de API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:25:03 -03:00

599 lines
16 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);
}
class HomeDashboard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.stats = null;
this.loading = false;
this.charts = {};
this.shadowRoot.innerHTML = `
<style>
:host {
/* Aliases locales que apuntan a los globals — los charts usan estos
nombres cortos. Cambiar el tema desde theme.css se propaga aquí. */
--line: var(--border);
--blue: var(--chart-blue);
--green: var(--chart-green);
--purple: var(--chart-purple);
--orange: var(--chart-orange);
--emerald: #10B981;
--pink: var(--chart-pink);
--gray: var(--chart-gray);
}
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
.container {
min-height: 100%;
background: var(--bg);
color: var(--text);
padding: 16px;
overflow-y: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.sync-info {
font-size: 12px;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 16px;
}
.chart-card {
background: var(--panel);
border-radius: 8px;
padding: 16px;
}
.chart-card.full-width {
grid-column: 1 / -1;
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.chart-container {
position: relative;
height: 250px;
}
.chart-container.tall {
height: 300px;
}
.chart-container.short {
height: 200px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--muted);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.kpi-card {
background: var(--panel);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.kpi-label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
}
.donut-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
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(--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(--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(--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: "rgba(59, 130, 246, 0.8)",
borderColor: "#3b82f6",
borderWidth: 1,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
x: {
ticks: { color: "#8aa0b5" },
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: "#25D366",
},
{
label: "Web",
data: webData,
backgroundColor: "#3b82f6",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: "#8aa0b5" },
},
},
scales: {
y: {
stacked: true,
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
x: {
stacked: true,
ticks: { color: "#8aa0b5" },
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: "#3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.1)",
fill: true,
tension: 0.3,
},
{
label: String(yoy.last_year || "Anterior"),
data: yoy.last_year_data || [],
borderColor: "#9CA3AF",
backgroundColor: "rgba(156, 163, 175, 0.1)",
fill: true,
tension: 0.3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: { color: "#8aa0b5" },
},
},
scales: {
y: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
x: {
ticks: { color: "#8aa0b5" },
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: ["#25D366", "#3b82f6"],
}],
},
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: ["#8B5CF6", "#F59E0B"],
}],
},
options: this.getDonutOptions(),
});
}
}
getDonutOptions() {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom",
labels: { color: "#8aa0b5" },
},
},
};
}
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: "rgba(59, 130, 246, 0.8)",
borderColor: "#3b82f6",
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
y: {
ticks: { color: "#8aa0b5" },
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: "rgba(139, 92, 246, 0.8)",
borderColor: "#8B5CF6",
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
y: {
ticks: { color: "#8aa0b5", 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: "rgba(245, 158, 11, 0.8)",
borderColor: "#F59E0B",
borderWidth: 1,
}],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
beginAtZero: true,
ticks: { color: "#8aa0b5" },
grid: { color: "#1e2a3a" },
},
y: {
ticks: { color: "#8aa0b5", font: { size: 10 } },
grid: { display: false },
},
},
},
});
}
}
customElements.define("home-dashboard", HomeDashboard);