diff --git a/db/migrations/20260127100000_woo_orders_cache.sql b/db/migrations/20260127100000_woo_orders_cache.sql
new file mode 100644
index 0000000..5197a57
--- /dev/null
+++ b/db/migrations/20260127100000_woo_orders_cache.sql
@@ -0,0 +1,90 @@
+-- migrate:up
+
+-- Tabla de cache de pedidos de WooCommerce
+-- Almacena pedidos localmente para estadísticas y listado rápido
+CREATE TABLE woo_orders_cache (
+ id SERIAL PRIMARY KEY,
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ woo_order_id BIGINT NOT NULL,
+ status VARCHAR(50),
+ total NUMERIC(12,2),
+ currency VARCHAR(10) DEFAULT 'ARS',
+ date_created TIMESTAMPTZ NOT NULL,
+ date_paid TIMESTAMPTZ,
+
+ -- Filtros del dashboard
+ source VARCHAR(20) DEFAULT 'web', -- 'whatsapp' | 'web'
+ is_delivery BOOLEAN DEFAULT false,
+ is_cash BOOLEAN DEFAULT false,
+
+ -- Cliente
+ customer_name VARCHAR(255),
+ customer_phone VARCHAR(50),
+ customer_email VARCHAR(255),
+
+ -- Dirección de envío (para futuro mapa de calor)
+ shipping_address_1 VARCHAR(255),
+ shipping_address_2 VARCHAR(255),
+ shipping_city VARCHAR(100),
+ shipping_state VARCHAR(100),
+ shipping_postcode VARCHAR(20),
+ shipping_country VARCHAR(10) DEFAULT 'AR',
+
+ -- Dirección de facturación
+ billing_address_1 VARCHAR(255),
+ billing_city VARCHAR(100),
+ billing_state VARCHAR(100),
+ billing_postcode VARCHAR(20),
+
+ -- Raw para debugging
+ raw JSONB,
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(tenant_id, woo_order_id)
+);
+
+CREATE INDEX idx_woo_orders_tenant_date ON woo_orders_cache(tenant_id, date_created DESC);
+CREATE INDEX idx_woo_orders_source ON woo_orders_cache(tenant_id, source);
+CREATE INDEX idx_woo_orders_city ON woo_orders_cache(tenant_id, shipping_city);
+
+-- Tabla de detalle de items (productos por pedido)
+-- Permite calcular stats por producto (kg vendidos, unidades, facturación)
+CREATE TABLE woo_order_items (
+ id SERIAL PRIMARY KEY,
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ woo_order_id BIGINT NOT NULL,
+ woo_product_id BIGINT,
+
+ -- Datos del producto
+ product_name VARCHAR(255) NOT NULL,
+ sku VARCHAR(100),
+
+ -- Cantidades y precios
+ quantity NUMERIC(10,3) NOT NULL, -- Soporta decimales para kg
+ unit_price NUMERIC(12,2), -- Precio unitario
+ line_total NUMERIC(12,2), -- quantity * unit_price
+
+ -- Tipo de unidad (para stats de kg vs unidades)
+ sell_unit VARCHAR(20) DEFAULT 'unit', -- 'kg' | 'unit' | 'pack'
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+
+ FOREIGN KEY (tenant_id, woo_order_id)
+ REFERENCES woo_orders_cache(tenant_id, woo_order_id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_woo_items_order ON woo_order_items(tenant_id, woo_order_id);
+CREATE INDEX idx_woo_items_product ON woo_order_items(tenant_id, woo_product_id);
+
+-- migrate:down
+
+DROP INDEX IF EXISTS idx_woo_items_product;
+DROP INDEX IF EXISTS idx_woo_items_order;
+DROP TABLE IF EXISTS woo_order_items;
+
+DROP INDEX IF EXISTS idx_woo_orders_city;
+DROP INDEX IF EXISTS idx_woo_orders_source;
+DROP INDEX IF EXISTS idx_woo_orders_tenant_date;
+DROP TABLE IF EXISTS woo_orders_cache;
diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml
new file mode 100644
index 0000000..c6e2e39
--- /dev/null
+++ b/docker-compose.override.yaml
@@ -0,0 +1,59 @@
+services:
+ app:
+ image: node:20-alpine
+ working_dir: /usr/src/app
+ command: sh -c "npm install && npm run dev"
+ ports:
+ - "3000:3000"
+ env_file:
+ - .env
+ environment:
+ - NODE_ENV=development
+ - PORT=3000
+ - DATABASE_URL=postgres://${POSTGRES_USER:-botino}:${POSTGRES_PASSWORD:-botino}@db:5432/${POSTGRES_DB:-botino}
+ - REDIS_URL=redis://redis:6379
+ volumes:
+ - .:/usr/src/app
+ - /usr/src/app/node_modules
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ restart: unless-stopped
+
+ db:
+ image: postgres:16-alpine
+ env_file:
+ - .env
+ environment:
+ - POSTGRES_DB=${POSTGRES_DB:-botino}
+ - POSTGRES_USER=${POSTGRES_USER:-botino}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-botino}
+ ports:
+ - "5432:5432"
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-botino}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ db_data:
+ redis_data:
diff --git a/docker-compose.yaml b/docker-compose.yaml
index c6e2e39..9c8342b 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -3,8 +3,8 @@ services:
image: node:20-alpine
working_dir: /usr/src/app
command: sh -c "npm install && npm run dev"
- ports:
- - "3000:3000"
+ expose:
+ - "3000"
env_file:
- .env
environment:
diff --git a/package-lock.json b/package-lock.json
index 6a35e5d..0372d54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"csv-parse": "^6.1.0",
"dotenv": "^17.2.3",
"express": "^4.19.2",
+ "mysql2": "^3.16.2",
"openai": "^6.15.0",
"pg": "^8.16.3",
"undici": "^7.16.0",
@@ -1249,6 +1250,15 @@
"js-tokens": "^9.0.1"
}
},
+ "node_modules/aws-ssl-profiles": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+ "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1480,6 +1490,15 @@
"ms": "2.0.0"
}
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1795,6 +1814,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1998,6 +2026,12 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -2073,6 +2107,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru.min": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
+ "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=1.30.0",
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wellwelwel"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2199,6 +2254,54 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/mysql2": {
+ "version": "3.16.2",
+ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
+ "integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
+ "license": "MIT",
+ "dependencies": {
+ "aws-ssl-profiles": "^1.1.2",
+ "denque": "^2.1.0",
+ "generate-function": "^2.3.1",
+ "iconv-lite": "^0.7.2",
+ "long": "^5.3.2",
+ "lru.min": "^1.1.3",
+ "named-placeholders": "^1.1.6",
+ "seq-queue": "^0.0.5",
+ "sqlstring": "^2.3.3"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/mysql2/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/named-placeholders": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
+ "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
+ "license": "MIT",
+ "dependencies": {
+ "lru.min": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2750,6 +2853,11 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/seq-queue": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
+ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
+ },
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -2882,6 +2990,15 @@
"node": ">= 10.x"
}
},
+ "node_modules/sqlstring": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
+ "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
diff --git a/package.json b/package.json
index 2238de9..7fd888e 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"csv-parse": "^6.1.0",
"dotenv": "^17.2.3",
"express": "^4.19.2",
+ "mysql2": "^3.16.2",
"openai": "^6.15.0",
"pg": "^8.16.3",
"undici": "^7.16.0",
diff --git a/public/app.js b/public/app.js
index 633fd08..fd21d20 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,4 +1,5 @@
import "./components/ops-shell.js";
+import "./components/home-dashboard.js";
import "./components/run-timeline.js";
import "./components/chat-simulator.js";
import "./components/conversation-inspector.js";
diff --git a/public/components/home-dashboard.js b/public/components/home-dashboard.js
new file mode 100644
index 0000000..5461104
--- /dev/null
+++ b/public/components/home-dashboard.js
@@ -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 = `
+
+
+
+
+
+
+
Ventas Totales por Mes
+
+
+
+
+
+
Web vs WhatsApp
+
+
+
+
+
+
Comparativa Año a Año
+
+
+
+
+
+
+
+
Delivery vs Retiro
+
+
+
+
+
+
Efectivo vs Tarjeta
+
+
+
+
+
+
+
Top Productos por Facturación
+
+
+
+
+
+
Top por Kg Vendidos
+
+
+
+
+
+
Top por Unidades
+
+
+
+
+
+
+ `;
+ }
+
+ 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 = `
+
+
${formatCurrency(totals.total_revenue)}
+
Total Facturado
+
+
+
${formatNumber(totals.total_orders)}
+
Pedidos
+
+
+
${formatCurrency(totals.by_source?.whatsapp)}
+
WhatsApp
+
+
+
${formatCurrency(totals.by_source?.web)}
+
Web
+
+ `;
+ }
+
+ 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);
diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js
index c4716bd..c9fee78 100644
--- a/public/components/ops-shell.js
+++ b/public/components/ops-shell.js
@@ -54,7 +54,8 @@ class OpsShell extends HTMLElement {
-
+
+
+
diff --git a/public/components/orders-crud.js b/public/components/orders-crud.js
index 18f34db..576ca59 100644
--- a/public/components/orders-crud.js
+++ b/public/components/orders-crud.js
@@ -41,6 +41,12 @@ class OrdersCrud extends HTMLElement {
this.orders = [];
this.selectedOrder = null;
this.loading = false;
+
+ // Paginación
+ this.page = 1;
+ this.limit = 50;
+ this.totalPages = 1;
+ this.totalOrders = 0;
this.shadowRoot.innerHTML = `
- Pedidos de WooCommerce
+ Pedidos
+
@@ -240,6 +297,25 @@ class OrdersCrud extends HTMLElement {
connectedCallback() {
this.shadowRoot.getElementById("btnRefresh").onclick = () => this.loadOrders();
+ // Paginación
+ this.shadowRoot.getElementById("limitSelect").onchange = (e) => {
+ this.limit = parseInt(e.target.value);
+ this.page = 1;
+ this.loadOrders();
+ };
+ this.shadowRoot.getElementById("btnPrev").onclick = () => {
+ if (this.page > 1) {
+ this.page--;
+ this.loadOrders();
+ }
+ };
+ this.shadowRoot.getElementById("btnNext").onclick = () => {
+ if (this.page < this.totalPages) {
+ this.page++;
+ this.loadOrders();
+ }
+ };
+
// Escuchar cambios de ruta para deep-linking
this._unsubRouter = on("router:viewChanged", ({ view, params }) => {
if (view === "orders" && params.id) {
@@ -248,7 +324,6 @@ class OrdersCrud extends HTMLElement {
});
// Escuchar nuevos pedidos para actualizar automáticamente
- // Usa retry con backoff porque WooCommerce puede tardar en devolver el pedido recién creado
this._unsubOrderCreated = on("order:created", ({ order_id }) => {
console.log("[orders-crud] order:created received, order_id:", order_id);
this.refreshWithRetry(order_id);
@@ -298,9 +373,17 @@ class OrdersCrud extends HTMLElement {
container.innerHTML = `
Cargando pedidos...
`;
try {
- const result = await api.listRecentOrders({ limit: 50 });
+ const result = await api.listOrders({ page: this.page, limit: this.limit });
this.orders = result.items || [];
+
+ // Actualizar paginación
+ if (result.pagination) {
+ this.totalPages = result.pagination.pages || 1;
+ this.totalOrders = result.pagination.total || 0;
+ }
+
this.renderTable();
+ this.updatePagination();
// Si hay un pedido pendiente de selección (deep-link), seleccionarlo
if (this._pendingOrderId) {
@@ -316,6 +399,22 @@ class OrdersCrud extends HTMLElement {
}
}
+ updatePagination() {
+ const pageInfo = this.shadowRoot.getElementById("pageInfo");
+ const totalInfo = this.shadowRoot.getElementById("totalInfo");
+ const btnPrev = this.shadowRoot.getElementById("btnPrev");
+ const btnNext = this.shadowRoot.getElementById("btnNext");
+ const limitSelect = this.shadowRoot.getElementById("limitSelect");
+
+ pageInfo.textContent = `Página ${this.page} de ${this.totalPages}`;
+ totalInfo.textContent = `${this.totalOrders.toLocaleString("es-AR")} pedidos`;
+
+ btnPrev.disabled = this.page <= 1;
+ btnNext.disabled = this.page >= this.totalPages;
+
+ limitSelect.value = String(this.limit);
+ }
+
renderTable() {
const container = this.shadowRoot.getElementById("ordersTable");
diff --git a/public/index.html b/public/index.html
index 163e23e..03cc8e9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,6 +7,7 @@
+