dashboard
This commit is contained in:
622
public/components/home-dashboard.js
Normal file
622
public/components/home-dashboard.js
Normal file
@@ -0,0 +1,622 @@
|
||||
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 {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121823;
|
||||
--muted: #8aa0b5;
|
||||
--text: #e7eef7;
|
||||
--line: #1e2a3a;
|
||||
--blue: #3b82f6;
|
||||
--green: #25D366;
|
||||
--purple: #8B5CF6;
|
||||
--orange: #F59E0B;
|
||||
--emerald: #10B981;
|
||||
--pink: #EC4899;
|
||||
--gray: #9CA3AF;
|
||||
}
|
||||
* { 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 class="chart-card">
|
||||
<div class="chart-title">Efectivo vs Tarjeta</div>
|
||||
<div class="chart-container short">
|
||||
<canvas id="payment-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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Payment donut
|
||||
const paymentCtx = this.shadowRoot.getElementById("payment-donut");
|
||||
if (paymentCtx) {
|
||||
if (this.charts.paymentDonut) this.charts.paymentDonut.destroy();
|
||||
this.charts.paymentDonut = new Chart(paymentCtx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: ["Efectivo", "Tarjeta"],
|
||||
datasets: [{
|
||||
data: [totals.by_payment?.cash || 0, totals.by_payment?.card || 0],
|
||||
backgroundColor: ["#10B981", "#EC4899"],
|
||||
}],
|
||||
},
|
||||
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);
|
||||
Reference in New Issue
Block a user